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

@@ -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({