feature: Phase3 - Task templates + generation on send

This commit is contained in:
2026-06-28 16:46:42 +03:30
parent 8b4ef6195d
commit 21f545ebdb
25 changed files with 1448 additions and 31 deletions

View File

@@ -0,0 +1,46 @@
-- Treatment workflow steps + lab case tasks
CREATE TYPE "LabTaskStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED');
CREATE TABLE "treatment_workflow_steps" (
"id" TEXT NOT NULL,
"treatmentType" TEXT NOT NULL,
"stepOrder" INTEGER NOT NULL,
"label" TEXT NOT NULL,
CONSTRAINT "treatment_workflow_steps_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "treatment_workflow_steps_treatmentType_stepOrder_key"
ON "treatment_workflow_steps"("treatmentType", "stepOrder");
CREATE TABLE "lab_case_tasks" (
"id" TEXT NOT NULL,
"labCaseId" TEXT NOT NULL,
"treatmentDetailId" TEXT NOT NULL,
"tooth" TEXT NOT NULL,
"treatmentType" TEXT NOT NULL,
"stepOrder" INTEGER NOT NULL,
"stepLabel" TEXT NOT NULL,
"assigneeUserId" TEXT,
"status" "LabTaskStatus" NOT NULL DEFAULT 'PENDING',
CONSTRAINT "lab_case_tasks_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_tooth_treatmentType_stepOrder_key"
ON "lab_case_tasks"("labCaseId", "tooth", "treatmentType", "stepOrder");
CREATE INDEX "lab_case_tasks_labCaseId_status_idx" ON "lab_case_tasks"("labCaseId", "status");
ALTER TABLE "lab_case_tasks"
ADD CONSTRAINT "lab_case_tasks_labCaseId_fkey"
FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "lab_case_tasks"
ADD CONSTRAINT "lab_case_tasks_treatmentDetailId_fkey"
FOREIGN KEY ("treatmentDetailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "lab_case_tasks"
ADD CONSTRAINT "lab_case_tasks_assigneeUserId_fkey"
FOREIGN KEY ("assigneeUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,49 @@
-- Treatment type catalog (data-driven; business logic reads from here)
CREATE TABLE "treatment_types" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"labDependent" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "treatment_types_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "treatment_types_code_key" ON "treatment_types"("code");
-- Temporary catalog (will be replaced with 14 real-world types later)
INSERT INTO "treatment_types" ("id", "code", "labDependent", "sortOrder") VALUES
('tt-consultation', 'consultation', false, 1),
('tt-filling', 'filling', false, 2),
('tt-endo', 'endo', true, 3),
('tt-visit', 'visit', false, 4),
('tt-hygiene', 'hygiene', false, 5);
-- Re-link workflow steps to catalog rows
ALTER TABLE "treatment_workflow_steps" ADD COLUMN "treatmentTypeId" TEXT;
UPDATE "treatment_workflow_steps" AS w
SET "treatmentTypeId" = t."id"
FROM "treatment_types" AS t
WHERE t."code" = w."treatmentType";
-- Drop steps for clinic-only types; only lab-dependent types keep workflows
DELETE FROM "treatment_workflow_steps" AS w
USING "treatment_types" AS t
WHERE w."treatmentTypeId" = t."id" AND t."labDependent" = false;
DELETE FROM "treatment_workflow_steps" WHERE "treatmentTypeId" IS NULL;
ALTER TABLE "treatment_workflow_steps" DROP CONSTRAINT IF EXISTS "treatment_workflow_steps_treatmentType_stepOrder_key";
DROP INDEX IF EXISTS "treatment_workflow_steps_treatmentType_stepOrder_key";
ALTER TABLE "treatment_workflow_steps" DROP COLUMN "treatmentType";
ALTER TABLE "treatment_workflow_steps" ALTER COLUMN "treatmentTypeId" SET NOT NULL;
ALTER TABLE "treatment_workflow_steps"
ADD CONSTRAINT "treatment_workflow_steps_treatmentTypeId_fkey"
FOREIGN KEY ("treatmentTypeId") REFERENCES "treatment_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE UNIQUE INDEX "treatment_workflow_steps_treatmentTypeId_stepOrder_key"
ON "treatment_workflow_steps"("treatmentTypeId", "stepOrder");

View File

@@ -23,6 +23,7 @@ model User {
sessions Session[] // 👈 ADD THIS - opposite relation for Session
sentStaffInvites StaffInvitation[]
sentOrganizationInvitations OrganizationInvitation[]
assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -118,6 +119,12 @@ enum TreatmentStatus {
COMPLETED
}
enum LabTaskStatus {
PENDING
IN_PROGRESS
COMPLETED
}
model Treatment {
id String @id @default(uuid())
organizationId String
@@ -154,6 +161,7 @@ model TreatmentDetail {
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
attachments TreatmentDetailAttachment[]
labCaseLink LabCaseDetail?
labCaseTasks LabCaseTask[]
@@index([treatmentId, sortOrder])
@@map("treatment_details")
@@ -190,6 +198,7 @@ model LabCase {
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
details LabCaseDetail[]
sends LabCaseSend[]
tasks LabCaseTask[]
@@index([treatmentId, sortOrder])
@@map("lab_cases")
@@ -219,6 +228,49 @@ model LabCaseSend {
@@map("lab_case_sends")
}
model TreatmentType {
id String @id @default(uuid())
code String @unique
labDependent Boolean @default(false)
sortOrder Int @default(0)
workflowSteps TreatmentWorkflowStep[]
@@map("treatment_types")
}
model TreatmentWorkflowStep {
id String @id @default(uuid())
treatmentTypeId String
stepOrder Int
label String
treatmentType TreatmentType @relation(fields: [treatmentTypeId], references: [id], onDelete: Cascade)
@@unique([treatmentTypeId, stepOrder])
@@map("treatment_workflow_steps")
}
model LabCaseTask {
id String @id @default(uuid())
labCaseId String
treatmentDetailId String
tooth String
treatmentType String
stepOrder Int
stepLabel String
assigneeUserId String?
status LabTaskStatus @default(PENDING)
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull)
@@unique([labCaseId, tooth, treatmentType, stepOrder])
@@index([labCaseId, status])
@@map("lab_case_tasks")
}
model Plan {
id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"

View File

@@ -1,5 +1,6 @@
// backend/prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import { randomUUID } from 'crypto';
import { config } from 'dotenv';
import path from 'path';
@@ -123,6 +124,57 @@ async function main() {
}
console.log('✅ Created features and permissions');
const workflowSteps = [
{ code: 'endo', stepOrder: 1, label: 'Access review' },
{ code: 'endo', stepOrder: 2, label: 'Fabrication' },
] as const;
const treatmentTypes = [
{ code: 'consultation', labDependent: false, sortOrder: 1 },
{ code: 'filling', labDependent: false, sortOrder: 2 },
{ code: 'endo', labDependent: true, sortOrder: 3 },
{ code: 'visit', labDependent: false, sortOrder: 4 },
{ code: 'hygiene', labDependent: false, sortOrder: 5 },
] as const;
for (const type of treatmentTypes) {
await prisma.treatmentType.upsert({
where: { code: type.code },
update: { labDependent: type.labDependent, sortOrder: type.sortOrder },
create: {
id: randomUUID(),
code: type.code,
labDependent: type.labDependent,
sortOrder: type.sortOrder,
},
});
}
console.log('✅ Seeded treatment type catalog');
for (const step of workflowSteps) {
const treatmentType = await prisma.treatmentType.findUniqueOrThrow({
where: { code: step.code },
select: { id: true },
});
await prisma.treatmentWorkflowStep.upsert({
where: {
treatmentTypeId_stepOrder: {
treatmentTypeId: treatmentType.id,
stepOrder: step.stepOrder,
},
},
update: { label: step.label },
create: {
id: randomUUID(),
treatmentTypeId: treatmentType.id,
stepOrder: step.stepOrder,
label: step.label,
},
});
}
console.log('✅ Seeded lab workflow steps');
console.log('🌱 Seeding completed successfully!');
}

View File

@@ -11,6 +11,8 @@ import { StaffModule } from './modules/staff/staff.module';
import { OrganizationModule } from './modules/organization/organization.module';
import { AppointmentsModule } from './modules/appointments/appointments.module';
import { TreatmentsModule } from './modules/treatments/treatments.module';
import { CasesModule } from './modules/cases/cases.module';
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
@Module({
imports: [
@@ -19,10 +21,12 @@ import { TreatmentsModule } from './modules/treatments/treatments.module';
load: [configurations],
}),
PrismaModule, // ✅ ADD THIS
TreatmentCatalogModule,
AuthModule,
PatientsModule,
AppointmentsModule,
TreatmentsModule,
CasesModule,
StaffModule,
OrganizationModule,
AdminModule.forRoot(),

View File

@@ -0,0 +1,25 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { assertLabOrganization } from '../../common/organization-type';
@Injectable()
export class LabOrgGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<{ user?: { organizationId?: string } }>();
const organizationId = request.user?.organizationId;
if (!organizationId) {
throw new UnauthorizedException('Organization is not selected');
}
await assertLabOrganization(this.prisma, organizationId);
return true;
}
}

View File

@@ -14,6 +14,7 @@ import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto';
import { UpdateAppointmentDto } from './dto/update-appointment.dto';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
const MS_PER_DAY = 86_400_000;
@@ -22,6 +23,7 @@ export class AppointmentsService {
constructor(
private readonly prisma: PrismaService,
private readonly staffWorkingHoursService: StaffWorkingHoursService,
private readonly treatmentCatalog: TreatmentCatalogService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -134,6 +136,7 @@ export class AppointmentsService {
await this.ensurePatientInOrg(dto.patientId, organizationId);
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
this.treatmentCatalog.assertKnownTreatmentType(dto.purpose);
await this.ensureAppointmentWithinProviderWorkingHours(
dto.providerUserId,
organizationId,
@@ -195,6 +198,8 @@ export class AppointmentsService {
const providerUserId = dto.providerUserId ?? existing.providerUserId;
const purpose = dto.purpose ?? existing.purpose;
this.treatmentCatalog.assertKnownTreatmentType(purpose);
await this.ensurePatientInOrg(patientId, organizationId);
await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId);
await this.ensureAppointmentWithinProviderWorkingHours(

View File

@@ -1,9 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsIn, IsUUID } from 'class-validator';
const APPOINTMENT_PURPOSES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
import { IsDateString, IsString, IsUUID, MaxLength } from 'class-validator';
export class CreateAppointmentDto {
@ApiProperty()
@@ -22,7 +18,11 @@ export class CreateAppointmentDto {
@IsDateString()
endAt: string;
@ApiProperty({ enum: APPOINTMENT_PURPOSES })
@IsIn([...APPOINTMENT_PURPOSES])
purpose: AppointmentPurpose;
@ApiProperty({
description: 'Treatment type code from the treatment catalog',
example: 'consultation',
})
@IsString()
@MaxLength(64)
purpose: string;
}

View File

@@ -0,0 +1,56 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CasesService } from './cases.service';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
@ApiTags('cases')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard, LabOrgGuard)
@Controller('cases')
export class CasesController {
constructor(private readonly casesService: CasesService) {}
@Get()
@ApiOperation({ summary: 'List lab cases received by this organization' })
list(@Query() query: ListLabCasesDto, @Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.list(organizationId, req.user.id, query);
}
@Get('assignable-members')
@ApiOperation({ summary: 'List lab staff who can be assigned to tasks' })
listAssignableMembers(@Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.listAssignableMembers(organizationId, req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
getOne(@Param('id') id: string, @Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.getOne(id, organizationId, req.user.id);
}
@Patch(':id/tasks/:taskId')
@ApiOperation({ summary: 'Update task assignee or status' })
updateTask(
@Param('id') id: string,
@Param('taskId') taskId: string,
@Body() dto: UpdateLabCaseTaskDto,
@Req() req,
) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { CasesController } from './cases.controller';
import { CasesService } from './cases.service';
@Module({
controllers: [CasesController],
providers: [CasesService, PrismaService, LabOrgGuard],
})
export class CasesModule {}

View File

@@ -0,0 +1,405 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
const labCaseListInclude = {
treatment: {
include: {
organization: { select: { id: true, name: true } },
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
appointment: { select: { startAt: true } },
},
},
details: {
include: {
detail: {
select: {
id: true,
treatmentType: true,
teeth: true,
comment: true,
},
},
},
},
sends: {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
tasks: {
orderBy: [
{ tooth: 'asc' as const },
{ treatmentType: 'asc' as const },
{ stepOrder: 'asc' as const },
],
include: {
assignee: { select: { id: true, name: true, email: true } },
},
},
} satisfies Prisma.LabCaseInclude;
@Injectable()
export class CasesService {
constructor(
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
if (query.treatmentType) {
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
}
const page = query.page ?? 1;
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
const skip = (page - 1) * limit;
const where: Prisma.LabCaseWhereInput = {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
...(query.clinicOrganizationId
? { treatment: { organizationId: query.clinicOrganizationId } }
: {}),
...(query.treatmentType
? {
details: {
some: { detail: { treatmentType: query.treatmentType } },
},
}
: {}),
...(query.q?.trim()
? this.buildSearchWhere(query.q.trim())
: {}),
};
const [items, total] = await Promise.all([
this.prisma.labCase.findMany({
where,
include: {
treatment: {
include: {
organization: { select: { id: true, name: true } },
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
},
},
details: {
include: {
detail: { select: { treatmentType: true } },
},
},
tasks: { select: { id: true, status: true } },
},
orderBy: [{ sentAt: 'desc' }],
skip,
take: limit,
}),
this.prisma.labCase.count({ where }),
]);
return {
success: true,
data: {
items: items.map((lc) => this.mapLabCaseListItem(lc)),
pagination: {
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
},
},
};
}
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const labCase = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
include: labCaseListInclude,
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
return { success: true, data: this.mapLabCaseDetail(labCase) };
}
async updateTask(
labCaseId: string,
taskId: string,
dto: UpdateLabCaseTaskDto,
labOrganizationId: string,
actorUserId: string,
) {
await this.assertCanEditCases(actorUserId, labOrganizationId);
const task = await this.prisma.labCaseTask.findFirst({
where: {
id: taskId,
labCaseId,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
});
if (!task) {
throw new NotFoundException('Task not found');
}
if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) {
await this.ensureLabMember(dto.assigneeUserId, labOrganizationId);
}
const updated = await this.prisma.labCaseTask.update({
where: { id: taskId },
data: {
...(dto.assigneeUserId !== undefined ? { assigneeUserId: dto.assigneeUserId } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
},
include: {
assignee: { select: { id: true, name: true, email: true } },
},
});
return { success: true, data: this.mapTask(updated) };
}
async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const memberships = await this.prisma.membership.findMany({
where: { organizationId: labOrganizationId, isActive: true },
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
});
return {
success: true,
data: memberships.map((m) => ({
userId: m.user.id,
name: m.user.name,
email: m.user.email,
isOwner: m.isOwner,
})),
};
}
private buildSearchWhere(q: string): Prisma.LabCaseWhereInput {
const orConditions: Prisma.LabCaseWhereInput[] = [
{
treatment: {
patient: {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
],
},
},
},
{
treatment: {
organization: { name: { contains: q, mode: 'insensitive' } },
},
},
];
const normalized = normalizeMobile(q);
if (normalized) {
orConditions.push({
treatment: { patient: { mobile: normalized } },
});
}
return { OR: orConditions };
}
private mapLabCaseListItem(lc: {
id: string;
sentAt: Date | null;
treatment: {
organization: { id: string; name: string };
patient: { id: string; firstName: string; lastName: string; mobile: string };
};
details: Array<{ detail: { treatmentType: string } }>;
tasks: Array<{ id: string; status: LabTaskStatus }>;
}) {
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;
return {
id: lc.id,
sentAt: lc.sentAt?.toISOString() ?? null,
clinic: lc.treatment.organization,
patient: {
id: lc.treatment.patient.id,
firstName: lc.treatment.patient.firstName,
lastName: lc.treatment.patient.lastName,
mobile: lc.treatment.patient.mobile,
},
treatmentTypes,
taskProgress: {
completed: completedTasks,
total: lc.tasks.length,
},
};
}
private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) {
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
const tasksByTooth = this.groupTasksByTooth(lc.tasks);
return {
id: lc.id,
sentAt: lc.sentAt?.toISOString() ?? null,
labComment: lc.labComment,
clinic: lc.treatment.organization,
patient: lc.treatment.patient,
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
treatmentTypes,
details: lc.details.map((link) => ({
id: link.detail.id,
treatmentType: link.detail.treatmentType,
teeth: normalizeTeeth(link.detail.teeth),
comment: link.detail.comment,
})),
sends: lc.sends.map((s) => ({
organizationId: s.organizationId,
organizationName: s.organization.name,
sentAt: s.sentAt.toISOString(),
})),
tasks: lc.tasks.map((t) => this.mapTask(t)),
tasksByTooth,
taskProgress: {
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
total: lc.tasks.length,
},
};
}
private groupTasksByTooth(
tasks: Array<{
id: string;
tooth: string;
treatmentType: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
assigneeUserId: string | null;
assignee: { id: string; name: string; email: string } | null;
}>,
) {
const groups = new Map<
string,
{
tooth: string;
treatmentType: string;
tasks: ReturnType<CasesService['mapTask']>[];
}
>();
for (const task of tasks) {
const key = `${task.tooth}:${task.treatmentType}`;
const entry = groups.get(key) ?? {
tooth: task.tooth,
treatmentType: task.treatmentType,
tasks: [],
};
entry.tasks.push(this.mapTask(task));
groups.set(key, entry);
}
return [...groups.values()];
}
private mapTask(task: {
id: string;
tooth: string;
treatmentType: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
assigneeUserId: string | null;
assignee: { id: string; name: string; email: string } | null;
}) {
return {
id: task.id,
tooth: task.tooth,
treatmentType: task.treatmentType,
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
assigneeUserId: task.assigneeUserId,
assignee: task.assignee
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
: null,
};
}
private async ensureLabMember(userId: string, labOrganizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId: labOrganizationId, isActive: true },
select: { id: true },
});
if (!membership) {
throw new BadRequestException('Assignee must be an active member of this lab');
}
}
private async assertCanReadCases(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (m.isOwner) return;
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_CASES_READ') || names.includes('TAB_CASES_EDIT')) {
return;
}
throw new ForbiddenException('You do not have access to cases');
}
private async assertCanEditCases(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (m.isOwner) return;
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_CASES_EDIT')) {
return;
}
throw new ForbiddenException('You cannot update cases');
}
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
});
}
}

View File

@@ -0,0 +1,41 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
import { LabTaskStatus } from '@prisma/client';
export class UpdateLabCaseTaskDto {
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsUUID()
assigneeUserId?: string | null;
@IsOptional()
@IsEnum(LabTaskStatus)
status?: LabTaskStatus;
}
export class ListLabCasesDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
clinicOrganizationId?: string;
@IsOptional()
@IsString()
treatmentType?: string;
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(100)
limit = 20;
}

View File

@@ -0,0 +1,91 @@
import { LabTaskStatus, Prisma } from '@prisma/client';
import { normalizeTeeth } from '../treatments/treatment.utils';
type TransactionClient = Prisma.TransactionClient;
export async function generateLabCaseTasks(
tx: TransactionClient,
labCaseId: string,
): Promise<number> {
const existingCount = await tx.labCaseTask.count({ where: { labCaseId } });
if (existingCount > 0) {
return 0;
}
const labCase = await tx.labCase.findUnique({
where: { id: labCaseId },
include: {
details: {
include: {
detail: {
select: { id: true, treatmentType: true, teeth: true },
},
},
},
},
});
if (!labCase?.details.length) {
return 0;
}
const treatmentTypeCodes = [...new Set(labCase.details.map((d) => d.detail.treatmentType))];
const labDependentTypes = await tx.treatmentType.findMany({
where: { code: { in: treatmentTypeCodes }, labDependent: true },
select: { id: true, code: true },
});
if (labDependentTypes.length === 0) {
return 0;
}
const labDependentCodes = new Set(labDependentTypes.map((t) => t.code));
const workflowSteps = await tx.treatmentWorkflowStep.findMany({
where: { treatmentTypeId: { in: labDependentTypes.map((t) => t.id) } },
orderBy: [{ treatmentTypeId: 'asc' }, { stepOrder: 'asc' }],
include: { treatmentType: { select: { code: true } } },
});
const stepsByTypeCode = new Map<string, { stepOrder: number; label: string }[]>();
for (const step of workflowSteps) {
const code = step.treatmentType.code;
const list = stepsByTypeCode.get(code) ?? [];
list.push({ stepOrder: step.stepOrder, label: step.label });
stepsByTypeCode.set(code, list);
}
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
for (const link of labCase.details) {
const detail = link.detail;
if (!labDependentCodes.has(detail.treatmentType)) {
continue;
}
const teeth = normalizeTeeth(detail.teeth);
const typeSteps = stepsByTypeCode.get(detail.treatmentType) ?? [];
for (const tooth of teeth) {
for (const step of typeSteps) {
taskRows.push({
labCaseId,
treatmentDetailId: detail.id,
tooth,
treatmentType: detail.treatmentType,
stepOrder: step.stepOrder,
stepLabel: step.label,
status: LabTaskStatus.PENDING,
});
}
}
}
if (taskRows.length === 0) {
return 0;
}
await tx.labCaseTask.createMany({ data: taskRows });
return taskRows.length;
}

View File

@@ -0,0 +1,21 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TreatmentCatalogService } from './treatment-catalog.service';
@ApiTags('treatment-catalog')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('treatment-catalog')
export class TreatmentCatalogController {
constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {}
@Get()
@ApiOperation({ summary: 'List treatment types from the catalog (data-driven)' })
list() {
return {
success: true,
data: this.treatmentCatalogService.list(),
};
}
}

View File

@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { TreatmentCatalogController } from './treatment-catalog.controller';
import { TreatmentCatalogService } from './treatment-catalog.service';
@Global()
@Module({
controllers: [TreatmentCatalogController],
providers: [TreatmentCatalogService, PrismaService],
exports: [TreatmentCatalogService],
})
export class TreatmentCatalogModule {}

View File

@@ -0,0 +1,69 @@
import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
export type TreatmentTypeCatalogEntry = {
id: string;
code: string;
labDependent: boolean;
sortOrder: number;
};
@Injectable()
export class TreatmentCatalogService implements OnModuleInit {
private loaded = false;
private byCode = new Map<string, TreatmentTypeCatalogEntry>();
constructor(private readonly prisma: PrismaService) {}
async onModuleInit() {
await this.refresh();
}
async refresh(): Promise<void> {
const rows = await this.prisma.treatmentType.findMany({
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
select: { id: true, code: true, labDependent: true, sortOrder: true },
});
this.byCode = new Map(rows.map((row) => [row.code, row]));
this.loaded = true;
}
list(): TreatmentTypeCatalogEntry[] {
this.ensureLoaded();
return [...this.byCode.values()];
}
getByCode(code: string): TreatmentTypeCatalogEntry | undefined {
this.ensureLoaded();
return this.byCode.get(code);
}
assertKnownTreatmentType(code: string): TreatmentTypeCatalogEntry {
const entry = this.getByCode(code);
if (!entry) {
throw new BadRequestException(`Unknown treatment type: ${code}`);
}
return entry;
}
assertLabDependentTreatmentType(code: string): TreatmentTypeCatalogEntry {
const entry = this.assertKnownTreatmentType(code);
if (!entry.labDependent) {
throw new BadRequestException(
`Treatment type "${code}" is completed in the clinic and cannot be sent to a lab`,
);
}
return entry;
}
isLabDependent(code: string): boolean {
return this.getByCode(code)?.labDependent ?? false;
}
private ensureLoaded() {
if (!this.loaded) {
throw new Error('Treatment catalog is not loaded yet');
}
}
}

View File

@@ -1,7 +1,6 @@
import {
ArrayMinSize,
IsArray,
IsIn,
IsOptional,
IsString,
IsUUID,
@@ -10,8 +9,6 @@ import {
} from 'class-validator';
import { Type } from 'class-transformer';
const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
export class SaveTreatmentDetailDto {
@IsString()
@MaxLength(64)
@@ -21,7 +18,8 @@ export class SaveTreatmentDetailDto {
@IsUUID()
id?: string;
@IsIn(TREATMENT_TYPES)
@IsString()
@MaxLength(64)
treatmentType: string;
@IsArray()

View File

@@ -1,13 +1,5 @@
import { TreatmentStatus } from '@prisma/client';
const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
export type TreatmentTypeValue = (typeof TREATMENT_TYPES)[number];
export function isTreatmentType(value: string): value is TreatmentTypeValue {
return (TREATMENT_TYPES as readonly string[]).includes(value);
}
const FDI_TOOTH_IDS = new Set([
'11', '12', '13', '14', '15', '16', '17', '18',
'21', '22', '23', '24', '25', '26', '27', '28',

View File

@@ -9,13 +9,14 @@ import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import {
SaveTreatmentDraftDto,
SaveTreatmentLabCasesDto,
} from './dto/treatment.dto';
import {
generateTreatmentTitle,
isTreatmentType,
mapTreatmentStatusForApi,
normalizeTeeth,
} from './treatment.utils';
@@ -61,7 +62,10 @@ const treatmentInclude = {
export class TreatmentsService {
private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments');
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
@@ -163,9 +167,7 @@ export class TreatmentsService {
);
for (const d of dto.details) {
if (!isTreatmentType(d.treatmentType)) {
throw new BadRequestException(`Invalid treatment type: ${d.treatmentType}`);
}
this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType);
}
const normalizedDetails = dto.details.map((d, index) => ({
@@ -328,12 +330,16 @@ export class TreatmentsService {
const details = await this.prisma.treatmentDetail.findMany({
where: { treatmentId: treatment.id, id: { in: detailIds } },
select: { id: true },
select: { id: true, treatmentType: true },
});
if (details.length !== uniqueDetailIds.size) {
throw new BadRequestException('One or more treatment details were not found');
}
for (const detail of details) {
this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType);
}
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
for (const lc of dto.labCases) {
@@ -470,6 +476,8 @@ export class TreatmentsService {
data: { sentAt: now },
});
}
await generateLabCaseTasks(tx, labCaseId);
});
const refreshed = await this.prisma.labCase.findUniqueOrThrow({