import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { LabTaskStatus, LinkStatus } from '@prisma/client'; 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 { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { SaveTreatmentDraftDto, SaveTreatmentLabCasesDto, } from './dto/treatment.dto'; import { generateTreatmentTitle, normalizeTeeth, } from './treatment.utils'; import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; import { hasEffectivePermission } from '../../common/membership-permissions'; const treatmentInclude = { details: { orderBy: [{ sortOrder: 'asc' as const }], include: { attachments: { orderBy: [{ createdAt: 'asc' as const }] }, labCaseLink: { include: { labCase: { include: { sends: { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, }, tasks: { select: { id: true, status: true } }, }, }, }, }, }, }, labCases: { orderBy: [{ sortOrder: 'asc' as const }], include: { details: { include: { detail: { select: { id: true, clientKey: true, treatmentType: true, teeth: true }, }, }, }, sends: { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, }, toothProsthesis: true, attachments: { include: { attachment: { select: { id: true, fileName: true, mimeType: true, sizeBytes: true, createdAt: true }, }, }, }, tasks: { select: { id: true, status: true } }, }, }, }; @Injectable() export class TreatmentsService { private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments'); constructor( private readonly prisma: PrismaService, private readonly treatmentCatalog: TreatmentCatalogService, private readonly prosthesisCatalog: ProsthesisCatalogService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { throw new BadRequestException('Organization is not selected'); } return user.organizationId; } async listLinkedOrganizations(userId: string, organizationId: string) { await this.assertCanReadTreatment(userId, organizationId); const [linksA, linksB] = await Promise.all([ this.prisma.organizationLink.findMany({ where: { organizationAId: organizationId, status: LinkStatus.ACTIVE }, include: { organizationB: { select: { id: true, name: true } } }, }), this.prisma.organizationLink.findMany({ where: { organizationBId: organizationId, status: LinkStatus.ACTIVE }, include: { organizationA: { select: { id: true, name: true } } }, }), ]); const data = [ ...linksA.map((l) => ({ id: l.organizationB.id, name: l.organizationB.name, active: true, })), ...linksB.map((l) => ({ id: l.organizationA.id, name: l.organizationA.name, active: true, })), ].sort((a, b) => a.name.localeCompare(b.name)); return { success: true, data }; } async listPatientHistory( patientId: string, organizationId: string, actorUserId: string, limit = 20, ) { await this.assertCanReadTreatment(actorUserId, organizationId); await this.ensurePatientExists(patientId); const membership = await this.getMembership(actorUserId, organizationId); const isOwner = membership?.isOwner ?? false; const items = await this.prisma.treatment.findMany({ where: { patientId, organizationId, details: { some: {} }, ...(isOwner ? {} : { OR: [ { providerUserId: actorUserId }, { appointment: { is: { providerUserId: actorUserId } } }, ], }), }, include: treatmentInclude, orderBy: [{ treatmentAt: 'desc' }, { createdAt: 'desc' }], take: Math.min(Math.max(limit, 1), 100), }); return { success: true, data: items.map((t) => this.mapTreatment(t)) }; } async getDraftForAppointment( appointmentId: string, organizationId: string, actorUserId: string, ) { await this.assertCanReadTreatment(actorUserId, organizationId); const appointment = await this.ensureAppointmentProvider( appointmentId, organizationId, actorUserId, false, ); const treatment = await this.prisma.treatment.findFirst({ where: { appointmentId: appointment.id, organizationId, }, include: treatmentInclude, }); return { success: true, data: treatment ? this.mapTreatment(treatment) : null }; } async saveDraftForAppointment( appointmentId: string, dto: SaveTreatmentDraftDto, organizationId: string, actorUserId: string, ) { await this.assertCanEditTreatment(actorUserId, organizationId); const appointment = await this.ensureAppointmentProvider( appointmentId, organizationId, actorUserId, true, ); for (const d of dto.details) { this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType); } const normalizedDetails = dto.details.map((d, index) => ({ ...d, sortOrder: index, teeth: normalizeTeeth(d.teeth), comment: d.comment?.trim() || null, attachmentIds: d.attachmentIds ?? [], })); const title = generateTreatmentTitle( normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })), ); const treatment = await this.prisma.$transaction(async (tx) => { const existing = await tx.treatment.findFirst({ where: { appointmentId: appointment.id, organizationId }, select: { id: true }, }); const saved = existing ? await tx.treatment.update({ where: { id: existing.id }, data: { title, treatmentAt: appointment.startAt, patientId: appointment.patientId, providerUserId: actorUserId, }, }) : await tx.treatment.create({ data: { organizationId, patientId: appointment.patientId, appointmentId: appointment.id, providerUserId: actorUserId, title, treatmentAt: appointment.startAt, }, }); const keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[]; const existingDetails = existing ? await tx.treatmentDetail.findMany({ where: { treatmentId: saved.id }, select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } }, }) : []; const lockedDetailIds = new Set( existingDetails .filter((d) => d.labCaseLink?.labCase.sentAt) .map((d) => d.id), ); const removableDetailIds = existingDetails .filter((d) => !keepDetailIds.includes(d.id) && !lockedDetailIds.has(d.id)) .map((d) => d.id); if (removableDetailIds.length > 0) { await tx.treatmentDetail.deleteMany({ where: { id: { in: removableDetailIds }, treatmentId: saved.id }, }); } for (const d of normalizedDetails) { if (d.id && lockedDetailIds.has(d.id)) { continue; } const row = d.id ? await tx.treatmentDetail.update({ where: { id: d.id }, data: { clientKey: d.clientId, sortOrder: d.sortOrder, treatmentType: d.treatmentType, teeth: d.teeth, comment: d.comment, }, }) : await tx.treatmentDetail.create({ data: { treatmentId: saved.id, clientKey: d.clientId, sortOrder: d.sortOrder, treatmentType: d.treatmentType, teeth: d.teeth, comment: d.comment, }, }); const allowedAttachmentIds = new Set(d.attachmentIds); const pendingAttachments = await tx.treatmentDetailAttachment.findMany({ where: { appointmentId: appointment.id, detailClientKey: d.clientId, }, }); for (const attachment of pendingAttachments) { if (!allowedAttachmentIds.has(attachment.id)) { await tx.treatmentDetailAttachment.delete({ where: { id: attachment.id } }); } else { await tx.treatmentDetailAttachment.update({ where: { id: attachment.id }, data: { detailId: row.id, appointmentId: null, detailClientKey: null }, }); } } await tx.treatmentDetailAttachment.deleteMany({ where: { detailId: row.id, id: { notIn: [...allowedAttachmentIds] }, }, }); } return tx.treatment.findUniqueOrThrow({ where: { id: saved.id }, include: treatmentInclude, }); }); return { success: true, data: this.mapTreatment(treatment) }; } async saveLabCasesForAppointment( appointmentId: string, dto: SaveTreatmentLabCasesDto, organizationId: string, actorUserId: string, ) { await this.assertCanEditTreatment(actorUserId, organizationId); const appointment = await this.ensureAppointmentProvider( appointmentId, organizationId, actorUserId, true, ); const treatment = await this.prisma.treatment.findFirst({ where: { appointmentId: appointment.id, organizationId }, select: { id: true }, }); if (!treatment) { throw new NotFoundException('Save treatment details before creating lab cases'); } const detailIds = dto.labCases.map((lc) => lc.treatmentDetailId); const uniqueDetailIds = new Set(detailIds); if (uniqueDetailIds.size !== detailIds.length) { throw new BadRequestException('Each treatment detail can belong to only one lab case'); } const details = await this.prisma.treatmentDetail.findMany({ where: { treatmentId: treatment.id, id: { in: detailIds } }, select: { id: true, treatmentType: true, teeth: 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 detailById = new Map(details.map((d) => [d.id, d])); const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); for (const lc of dto.labCases) { if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) { throw new BadRequestException('Destination organization is not an active linked counterpart'); } for (const row of lc.toothProsthesis ?? []) { if (lc.treatmentDetailId !== row.treatmentDetailId) { throw new BadRequestException( 'Tooth prosthesis must reference the lab case treatment detail', ); } const detail = detailById.get(row.treatmentDetailId); if (!detail) { throw new BadRequestException('Tooth prosthesis references an unknown treatment detail'); } const teeth = normalizeTeeth(detail.teeth); if (!teeth.includes(row.tooth)) { throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`); } this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode); } } const saved = await this.prisma.$transaction(async (tx) => { const existingLabCases = await tx.labCase.findMany({ where: { treatmentId: treatment.id }, select: { id: true, sentAt: true }, }); const sentLabCaseIds = new Set(existingLabCases.filter((lc) => lc.sentAt).map((lc) => lc.id)); const keepLabCaseIds = dto.labCases.map((lc) => lc.id).filter(Boolean) as string[]; const removableLabCaseIds = existingLabCases .filter((lc) => !keepLabCaseIds.includes(lc.id) && !lc.sentAt) .map((lc) => lc.id); if (removableLabCaseIds.length > 0) { await tx.labCase.deleteMany({ where: { id: { in: removableLabCaseIds }, treatmentId: treatment.id }, }); } for (const [index, lc] of dto.labCases.entries()) { if (lc.id && sentLabCaseIds.has(lc.id)) { continue; } const row = lc.id ? await tx.labCase.update({ where: { id: lc.id }, data: { clientKey: lc.clientId, sortOrder: index, destinationOrganizationId: lc.destinationOrganizationId ?? null, }, }) : await tx.labCase.create({ data: { treatmentId: treatment.id, clientKey: lc.clientId, sortOrder: index, destinationOrganizationId: lc.destinationOrganizationId ?? null, }, }); await tx.labCaseDetail.deleteMany({ where: { labCaseId: row.id } }); await tx.labCaseDetail.create({ data: { labCaseId: row.id, treatmentDetailId: lc.treatmentDetailId, }, }); await tx.labCaseToothProsthesis.deleteMany({ where: { labCaseId: row.id } }); if (lc.toothProsthesis?.length) { await tx.labCaseToothProsthesis.createMany({ data: lc.toothProsthesis.map((tp) => ({ labCaseId: row.id, treatmentDetailId: tp.treatmentDetailId, tooth: tp.tooth, prosthesisTypeCode: tp.prosthesisTypeCode, })), }); } await tx.labCaseAttachment.deleteMany({ where: { labCaseId: row.id } }); const attachmentIds = lc.attachmentIds ?? []; if (attachmentIds.length > 0) { const validAttachments = await tx.treatmentDetailAttachment.findMany({ where: { id: { in: attachmentIds }, detailId: lc.treatmentDetailId, }, select: { id: true }, }); if (validAttachments.length !== attachmentIds.length) { throw new BadRequestException( 'One or more attachments are invalid for this lab case', ); } await tx.labCaseAttachment.createMany({ data: attachmentIds.map((attachmentId) => ({ labCaseId: row.id, attachmentId, })), }); } } return tx.treatment.findUniqueOrThrow({ where: { id: treatment.id }, include: treatmentInclude, }); }); return { success: true, data: this.mapTreatment(saved) }; } async sendLabCase( labCaseId: string, organizationId: string, actorUserId: string, actorLanguage?: string | null, ) { await this.assertCanEditTreatment(actorUserId, organizationId); const labCase = await this.prisma.labCase.findFirst({ where: { id: labCaseId, treatment: { organizationId }, }, include: { treatment: { select: { providerUserId: true } }, sends: { select: { organizationId: true } }, details: { include: { detail: { select: { id: true, treatmentType: true, teeth: true } }, }, }, toothProsthesis: true, }, }); if (!labCase) { throw new NotFoundException('Lab case not found'); } if (!labCase.destinationOrganizationId) { throw new BadRequestException('Lab case has no destination organization'); } if (labCase.details.length === 0) { throw new BadRequestException('Lab case must include a treatment detail'); } if (labCase.details.length > 1) { throw new BadRequestException('Lab case can include only one treatment detail'); } assertCompleteToothProsthesisMap(labCase); if (labCase.treatment.providerUserId !== actorUserId) { const membership = await this.getMembership(actorUserId, organizationId); if (!membership?.isOwner) { throw new ForbiddenException('Only the appointment provider can send this lab case'); } } const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); if (!linkedOrgIds.has(labCase.destinationOrganizationId)) { throw new BadRequestException('Destination organization is not an active linked counterpart'); } const alreadySent = labCase.sends.some( (s) => s.organizationId === labCase.destinationOrganizationId, ); if (alreadySent) { throw new BadRequestException('Lab case was already sent to the destination organization'); } const now = new Date(); await this.prisma.$transaction(async (tx) => { await tx.labCaseSend.create({ data: { labCaseId, organizationId: labCase.destinationOrganizationId!, }, }); if (!labCase.sentAt) { await tx.labCase.update({ where: { id: labCaseId }, data: { sentAt: now }, }); } await generateLabCaseTasks(tx, labCaseId, actorLanguage); }); const refreshed = await this.prisma.labCase.findUniqueOrThrow({ where: { id: labCaseId }, include: { details: { include: { detail: { select: { id: true, clientKey: true, treatmentType: true, teeth: true }, }, }, }, sends: { orderBy: [{ sentAt: 'asc' }], include: { organization: { select: { id: true, name: true } } }, }, toothProsthesis: true, tasks: { select: { id: true, status: true } }, }, }); return { success: true, data: this.mapLabCase(refreshed) }; } async uploadDetailAttachments( appointmentId: string, detailClientKey: string, files: Express.Multer.File[], organizationId: string, actorUserId: string, ) { await this.assertCanEditTreatment(actorUserId, organizationId); await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true); if (!detailClientKey?.trim()) { throw new BadRequestException('detailClientKey is required'); } if (!files?.length) { throw new BadRequestException('At least one file is required'); } const orgDir = join(this.uploadRoot, organizationId); mkdirSync(orgDir, { recursive: true }); const created: { id: string; fileName: string; mimeType: string; sizeBytes: number; }[] = []; for (const file of files) { const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`; const storagePath = join(orgDir, storageName); const { writeFileSync } = await import('fs'); writeFileSync(storagePath, file.buffer); const attachment = await this.prisma.treatmentDetailAttachment.create({ data: { appointmentId, detailClientKey, fileName: file.originalname, mimeType: file.mimetype || 'application/octet-stream', sizeBytes: file.size, storagePath, }, }); created.push(this.mapAttachment(attachment)); } return { success: true, data: created }; } async streamAttachmentFile( attachmentId: string, organizationId: string, actorUserId: string, ) { await this.assertCanReadTreatment(actorUserId, organizationId); const attachment = await this.prisma.treatmentDetailAttachment.findFirst({ where: { id: attachmentId, OR: [ { detail: { treatment: { organizationId } } }, { appointmentId: { not: null } }, ], }, include: { detail: { select: { treatment: { select: { organizationId: true } } } }, }, }); if (!attachment) { throw new NotFoundException('Attachment not found'); } if (attachment.detail && attachment.detail.treatment.organizationId !== organizationId) { throw new NotFoundException('Attachment not found'); } if (!attachment.detail && attachment.appointmentId) { const appointment = await this.prisma.appointment.findFirst({ where: { id: attachment.appointmentId, organizationId }, select: { id: true }, }); if (!appointment) { throw new NotFoundException('Attachment not found'); } } if (!existsSync(attachment.storagePath)) { throw new NotFoundException('File is no longer available'); } return { stream: createReadStream(attachment.storagePath), fileName: attachment.fileName, mimeType: attachment.mimeType, }; } private mapTreatment(treatment: { id: string; patientId: string; appointmentId: string | null; title: string; treatmentAt: Date; details: Array<{ id: string; clientKey: string | null; treatmentType: string; teeth: unknown; comment: string | null; attachments: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; }>; labCaseLink?: { labCase: { id: string; sentAt: Date | null; destinationOrganizationId: string | null; sends: Array<{ organizationId: string; sentAt: Date; organization: { id: string; name: string }; }>; }; } | null; }>; labCases: Array<{ id: string; clientKey: string | null; sortOrder: number; destinationOrganizationId: string | null; sentAt: Date | null; details: Array<{ treatmentDetailId: string; detail: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; }>; sends: Array<{ organizationId: string; sentAt: Date; organization: { id: string; name: string }; }>; }>; }) { const documents = treatment.details.flatMap((d) => d.attachments.map((a) => this.mapAttachment(a)), ); return { id: treatment.id, patientId: treatment.patientId, appointmentId: treatment.appointmentId, title: treatment.title, treatmentAt: treatment.treatmentAt.toISOString(), details: treatment.details.map((d) => this.mapDetail(d)), labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)), documents, }; } private mapDetail(d: { id: string; clientKey?: string | null; treatmentType: string; teeth: unknown; comment?: string | null; attachments?: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; }>; labCaseLink?: { labCase: { id: string; sentAt: Date | null; destinationOrganizationId: string | null; sends: Array<{ organizationId: string; sentAt: Date; organization?: { id: string; name: string }; }>; tasks?: Array<{ id: string; status: LabTaskStatus }>; }; } | null; }) { const labCase = d.labCaseLink?.labCase; const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []); return { id: d.id, clientId: d.clientKey ?? d.id, treatmentType: d.treatmentType, teeth: normalizeTeeth(d.teeth), notes: d.comment ?? null, attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)), labCaseId: labCase?.id ?? null, sentAt: labCase?.sentAt?.toISOString() ?? null, destinationOrganizationId: labCase?.destinationOrganizationId ?? null, taskProgress, sends: labCase?.sends.map((s) => ({ organizationId: s.organizationId, organizationName: s.organization?.name ?? 'Unknown organization', sentAt: s.sentAt.toISOString(), })) ?? [], }; } private mapLabCase(lc: { id: string; clientKey?: string | null; sortOrder?: number; destinationOrganizationId?: string | null; sentAt?: Date | null; details?: Array<{ treatmentDetailId: string; detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; }>; sends?: Array<{ organizationId: string; sentAt: Date; organization?: { id: string; name: string }; }>; toothProsthesis?: Array<{ treatmentDetailId: string; tooth: string; prosthesisTypeCode: string; }>; attachments?: Array<{ attachment: { id: string; fileName: string; mimeType: string; sizeBytes: number; createdAt: Date; }; }>; }) { return { id: lc.id, clientId: lc.clientKey ?? lc.id, destinationOrganizationId: lc.destinationOrganizationId ?? null, sentAt: lc.sentAt?.toISOString() ?? null, treatmentDetailId: lc.details?.[0]?.treatmentDetailId ?? null, detail: lc.details?.[0] ? { id: lc.details[0].detail?.id ?? lc.details[0].treatmentDetailId, clientId: lc.details[0].detail?.clientKey ?? lc.details[0].treatmentDetailId, treatmentType: lc.details[0].detail?.treatmentType ?? '', teeth: lc.details[0].detail ? normalizeTeeth(lc.details[0].detail.teeth) : [], } : null, toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({ treatmentDetailId: tp.treatmentDetailId, tooth: tp.tooth, prosthesisTypeCode: tp.prosthesisTypeCode, })), attachments: (lc.attachments ?? []).map((row) => ({ id: row.attachment.id, fileName: row.attachment.fileName, mimeType: row.attachment.mimeType, sizeBytes: row.attachment.sizeBytes, createdAt: row.attachment.createdAt.toISOString(), })), sends: lc.sends?.map((s) => ({ organizationId: s.organizationId, organizationName: s.organization?.name ?? 'Unknown organization', sentAt: s.sentAt.toISOString(), })) ?? [], }; } private mapTaskProgress(tasks: Array<{ status: LabTaskStatus }>) { const total = tasks.length; const completed = tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length; return { completed, total }; } private mapAttachment(a: { id: string; fileName: string; mimeType: string; sizeBytes: number; }) { return { id: a.id, fileName: a.fileName, mimeType: a.mimeType, sizeBytes: a.sizeBytes, }; } private async getActiveLinkedOrganizationIds(organizationId: string) { const [linksA, linksB] = await Promise.all([ this.prisma.organizationLink.findMany({ where: { organizationAId: organizationId, status: LinkStatus.ACTIVE }, select: { organizationBId: true }, }), this.prisma.organizationLink.findMany({ where: { organizationBId: organizationId, status: LinkStatus.ACTIVE }, select: { organizationAId: true }, }), ]); return new Set([ ...linksA.map((l) => l.organizationBId), ...linksB.map((l) => l.organizationAId), ]); } private async ensurePatientExists(patientId: string) { const patient = await this.prisma.patient.findUnique({ where: { id: patientId }, select: { id: true }, }); if (!patient) { throw new NotFoundException('Patient not found'); } } private async ensureAppointmentProvider( appointmentId: string, organizationId: string, actorUserId: string, requireProviderMatch: boolean, ) { const appointment = await this.prisma.appointment.findFirst({ where: { id: appointmentId, organizationId }, select: { id: true, patientId: true, providerUserId: true, startAt: true, }, }); if (!appointment) { throw new NotFoundException('Appointment not found'); } if (requireProviderMatch) { const membership = await this.getMembership(actorUserId, organizationId); const isOwner = membership?.isOwner ?? false; if (!isOwner && appointment.providerUserId !== actorUserId) { throw new ForbiddenException('You are not the provider for this appointment'); } } return appointment; } private async assertCanReadTreatment(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 ( hasEffectivePermission(m, 'TAB_TREATMENT_READ') || hasEffectivePermission(m, 'TAB_TREATMENT_EDIT') ) { return; } throw new ForbiddenException('You do not have access to treatments'); } private async assertCanEditTreatment(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 (hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) { return; } throw new ForbiddenException('You cannot edit treatments'); } private async getMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId, OR: [{ isOwner: true }, { isActive: true }], }, include: { permissions: { include: { permission: true } }, organization: { include: { type: true, plan: true } }, }, }); } }