import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { LinkStatus, TreatmentStatus } from '@prisma/client'; import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; import { generateTreatmentTitle, isTreatmentType, mapTreatmentStatusForApi, normalizeTeeth, } from './treatment.utils'; const treatmentInclude = { cases: { orderBy: [{ sortOrder: 'asc' as const }], include: { attachments: { orderBy: [{ createdAt: 'asc' as const }] }, sends: { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, }, }, }, }; @Injectable() export class TreatmentsService { private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments'); constructor(private readonly prisma: PrismaService) {} 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.ensurePatientInOrg(patientId, organizationId); const items = await this.prisma.treatment.findMany({ where: { patientId, organizationId, status: TreatmentStatus.COMPLETED, }, include: treatmentInclude, orderBy: [{ treatmentAt: '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, status: TreatmentStatus.DRAFT, }, 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 c of dto.cases) { if (!isTreatmentType(c.treatmentType)) { throw new BadRequestException(`Invalid treatment type: ${c.treatmentType}`); } } const normalizedCases = dto.cases.map((c, index) => ({ ...c, sortOrder: index, teeth: normalizeTeeth(c.teeth), comment: c.comment?.trim() || null, attachmentIds: c.attachmentIds ?? [], })); const title = generateTreatmentTitle( normalizedCases.map((c) => ({ treatmentType: c.treatmentType, teeth: c.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: appointment.providerUserId, status: TreatmentStatus.DRAFT, }, }) : await tx.treatment.create({ data: { organizationId, patientId: appointment.patientId, appointmentId: appointment.id, providerUserId: appointment.providerUserId, title, status: TreatmentStatus.DRAFT, treatmentAt: appointment.startAt, }, }); const keepCaseIds = normalizedCases.map((c) => c.id).filter(Boolean) as string[]; const existingCases = existing ? await tx.treatmentCase.findMany({ where: { treatmentId: saved.id }, select: { id: true, sentAt: true }, }) : []; const sentCaseIds = new Set( existingCases.filter((c) => c.sentAt).map((c) => c.id), ); const removableCaseIds = existingCases .filter((c) => !keepCaseIds.includes(c.id) && !c.sentAt) .map((c) => c.id); if (removableCaseIds.length > 0) { await tx.treatmentCase.deleteMany({ where: { id: { in: removableCaseIds }, treatmentId: saved.id }, }); } for (const c of normalizedCases) { if (c.id && sentCaseIds.has(c.id)) { continue; } const row = c.id ? await tx.treatmentCase.update({ where: { id: c.id }, data: { clientKey: c.clientId, sortOrder: c.sortOrder, treatmentType: c.treatmentType, teeth: c.teeth, comment: c.comment, }, }) : await tx.treatmentCase.create({ data: { treatmentId: saved.id, clientKey: c.clientId, sortOrder: c.sortOrder, treatmentType: c.treatmentType, teeth: c.teeth, comment: c.comment, }, }); const allowedAttachmentIds = new Set(c.attachmentIds); const pendingAttachments = await tx.treatmentCaseAttachment.findMany({ where: { appointmentId: appointment.id, caseClientKey: c.clientId, }, }); for (const attachment of pendingAttachments) { if (!allowedAttachmentIds.has(attachment.id)) { await tx.treatmentCaseAttachment.delete({ where: { id: attachment.id } }); } else { await tx.treatmentCaseAttachment.update({ where: { id: attachment.id }, data: { caseId: row.id, appointmentId: null, caseClientKey: null }, }); } } await tx.treatmentCaseAttachment.deleteMany({ where: { caseId: row.id, id: { notIn: [...allowedAttachmentIds] }, }, }); } return tx.treatment.findUniqueOrThrow({ where: { id: saved.id }, include: treatmentInclude, }); }); return { success: true, data: this.mapTreatment(treatment) }; } async sendCase( caseId: string, dto: SendTreatmentCaseDto, organizationId: string, actorUserId: string, ) { await this.assertCanEditTreatment(actorUserId, organizationId); const treatmentCase = await this.prisma.treatmentCase.findFirst({ where: { id: caseId, treatment: { organizationId }, }, include: { treatment: { select: { providerUserId: true, appointmentId: true } }, sends: { select: { organizationId: true } }, }, }); if (!treatmentCase) { throw new NotFoundException('Treatment case not found'); } if (treatmentCase.treatment.providerUserId !== actorUserId) { const membership = await this.getMembership(actorUserId, organizationId); if (!membership?.isOwner) { throw new ForbiddenException('Only the appointment provider can send this case'); } } const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); const uniqueTargets = [...new Set(dto.organizationIds)]; for (const orgId of uniqueTargets) { if (!linkedOrgIds.has(orgId)) { throw new BadRequestException('One or more organizations are not active linked counterparts'); } } const alreadySent = new Set(treatmentCase.sends.map((s) => s.organizationId)); const newTargets = uniqueTargets.filter((id) => !alreadySent.has(id)); if (newTargets.length === 0) { throw new BadRequestException('Case was already sent to all selected organizations'); } const now = new Date(); await this.prisma.$transaction(async (tx) => { await tx.treatmentCaseSend.createMany({ data: newTargets.map((organizationId) => ({ caseId, organizationId, })), }); if (!treatmentCase.sentAt) { await tx.treatmentCase.update({ where: { id: caseId }, data: { sentAt: now }, }); } }); const refreshed = await this.prisma.treatmentCase.findUniqueOrThrow({ where: { id: caseId }, include: { attachments: { orderBy: [{ createdAt: 'asc' }] }, sends: { orderBy: [{ sentAt: 'asc' }], include: { organization: { select: { id: true, name: true } } }, }, }, }); return { success: true, data: this.mapCase(refreshed) }; } async uploadCaseAttachments( appointmentId: string, caseClientKey: string, files: Express.Multer.File[], organizationId: string, actorUserId: string, ) { await this.assertCanEditTreatment(actorUserId, organizationId); await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true); if (!caseClientKey?.trim()) { throw new BadRequestException('caseClientKey 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.treatmentCaseAttachment.create({ data: { appointmentId, caseClientKey, 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.treatmentCaseAttachment.findFirst({ where: { id: attachmentId, OR: [ { case: { treatment: { organizationId } } }, { appointmentId: { not: null } }, ], }, include: { case: { select: { treatment: { select: { organizationId: true } } } }, }, }); if (!attachment) { throw new NotFoundException('Attachment not found'); } if (attachment.case && attachment.case.treatment.organizationId !== organizationId) { throw new NotFoundException('Attachment not found'); } if (!attachment.case && 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; status: TreatmentStatus; treatmentAt: Date; cases: Array<{ id: string; clientKey: string | null; treatmentType: string; teeth: unknown; comment: string | null; sentAt: Date | null; attachments: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; }>; sends: Array<{ organizationId: string; sentAt: Date; organization: { id: string; name: string } }>; }>; }) { const documents = treatment.cases.flatMap((c) => c.attachments.map((a) => this.mapAttachment(a)), ); return { id: treatment.id, patientId: treatment.patientId, appointmentId: treatment.appointmentId, title: treatment.title, treatmentAt: treatment.treatmentAt.toISOString(), status: mapTreatmentStatusForApi(treatment.status), cases: treatment.cases.map((c) => this.mapCase(c)), documents, }; } private mapCase(c: { id: string; clientKey?: string | null; treatmentType: string; teeth: unknown; comment?: string | null; sentAt?: Date | null; attachments?: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; }>; sends?: Array<{ organizationId: string; sentAt: Date; organization?: { id: string; name: string } }>; }) { return { id: c.id, clientId: c.clientKey ?? c.id, treatmentType: c.treatmentType, teeth: normalizeTeeth(c.teeth), notes: c.comment ?? null, sentAt: c.sentAt?.toISOString() ?? null, sendToOrganizationIds: c.sends?.map((s) => s.organizationId) ?? [], sends: c.sends?.map((s) => ({ organizationId: s.organizationId, organizationName: s.organization?.name ?? 'Unknown organization', sentAt: s.sentAt.toISOString(), })) ?? [], attachmentMetas: (c.attachments ?? []).map((a) => this.mapAttachment(a)), }; } 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 ensurePatientInOrg(patientId: string, organizationId: string) { const patient = await this.prisma.patient.findFirst({ where: { id: patientId, organizationId }, 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 (m.isOwner) return; const names = m.permissions.map((p) => p.permission.name); if (names.includes('TAB_TREATMENT_READ') || names.includes('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 (m.isOwner) return; const names = m.permissions.map((p) => p.permission.name); if (names.includes('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, isActive: true }, include: { permissions: { include: { permission: true } } }, }); } }