import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { createReadStream, existsSync } from 'fs'; import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; import { CatalogLabelService, normalizeCatalogLocale, } from '../catalog/catalog-label.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto'; import { normalizeTaskTeeth } from './lab-case-task.util'; 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: [ { treatmentDetailId: 'asc' as const }, { prosthesisTypeCode: 'asc' as const }, { stepOrder: 'asc' as const }, ], include: { lastStatusChangedBy: { select: { id: true, name: true } }, statusEvents: { orderBy: { changedAt: 'asc' as const }, include: { changedBy: { select: { id: true, name: true } } }, }, }, }, toothProsthesis: true, attachments: { include: { attachment: { select: { id: true, fileName: true, mimeType: true, sizeBytes: true, createdAt: true, }, }, }, }, } satisfies Prisma.LabCaseInclude; type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{ include: { lastStatusChangedBy: { select: { id: true; name: true } }; statusEvents: { include: { changedBy: { select: { id: true; name: true } } }; }; }; }>; @Injectable() export class CasesService { constructor( private readonly prisma: PrismaService, private readonly treatmentCatalog: TreatmentCatalogService, private readonly catalogLabels: CatalogLabelService, ) {} 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 = this.buildListWhere(labOrganizationId, query); 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 listFilterOptions(labOrganizationId: string, actorUserId: string) { await this.assertCanReadCases(actorUserId, labOrganizationId); const rows = await this.prisma.labCase.findMany({ where: { sentAt: { not: null }, sends: { some: { organizationId: labOrganizationId } }, }, select: { treatment: { select: { organization: { select: { id: true, name: true } }, }, }, details: { select: { detail: { select: { treatmentType: true } } }, }, }, }); const clinicsById = new Map(); const typeCodes = new Set(); for (const row of rows) { clinicsById.set(row.treatment.organization.id, row.treatment.organization); for (const link of row.details) { typeCodes.add(link.detail.treatmentType); } } const catalog = await this.treatmentCatalog.list(); const treatmentTypes = catalog .filter((entry) => entry.labDependent && typeCodes.has(entry.code)) .map((entry) => ({ code: entry.code, labDependent: entry.labDependent })); return { success: true, data: { clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)), treatmentTypes, }, }; } /** Cases exchanged between one clinic and one lab (Organizations connection history). */ async listBetweenOrganizations( clinicOrganizationId: string, labOrganizationId: string, query: ListLabCasesDto, ) { 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 = { ...this.buildListWhere(labOrganizationId, query), treatment: { organizationId: clinicOrganizationId }, sends: { some: { organizationId: labOrganizationId } }, }; 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 getOneBetweenOrganizations( labCaseId: string, clinicOrganizationId: string, labOrganizationId: string, localeInput?: string | null, ) { const labCase = await this.prisma.labCase.findFirst({ where: { id: labCaseId, sentAt: { not: null }, treatment: { organizationId: clinicOrganizationId }, sends: { some: { organizationId: labOrganizationId } }, }, include: labCaseListInclude, }); if (!labCase) { throw new NotFoundException('Case not found'); } return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput), }; } async getOne( labCaseId: string, labOrganizationId: string, actorUserId: string, localeInput?: string | null, ) { 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: await this.mapLabCaseDetail(labCase, localeInput) }; } async streamCaseAttachment( labCaseId: string, attachmentId: string, labOrganizationId: string, actorUserId: string, ) { await this.assertCanReadCases(actorUserId, labOrganizationId); const link = await this.prisma.labCaseAttachment.findFirst({ where: { labCaseId, attachmentId, labCase: { sentAt: { not: null }, sends: { some: { organizationId: labOrganizationId } }, }, }, include: { attachment: { select: { storagePath: true, fileName: true, mimeType: true } }, }, }); if (!link?.attachment) { throw new NotFoundException('Attachment not found'); } if (!existsSync(link.attachment.storagePath)) { throw new NotFoundException('Attachment file is missing on disk'); } return { stream: createReadStream(link.attachment.storagePath), fileName: link.attachment.fileName, mimeType: link.attachment.mimeType, }; } async setCaseImportant( labCaseId: string, dto: UpdateLabCaseImportantDto, labOrganizationId: string, actorUserId: string, localeInput?: string | null, ) { await this.assertCanEditCases(actorUserId, labOrganizationId); const existing = await this.prisma.labCase.findFirst({ where: { id: labCaseId, sentAt: { not: null }, sends: { some: { organizationId: labOrganizationId } }, }, select: { id: true }, }); if (!existing) { throw new NotFoundException('Case not found'); } await this.prisma.labCase.update({ where: { id: labCaseId }, data: { isImportant: dto.isImportant }, }); const labCase = await this.prisma.labCase.findFirstOrThrow({ where: { id: labCaseId }, include: labCaseListInclude, }); return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; } private buildListWhere( labOrganizationId: string, query: ListLabCasesDto, ): Prisma.LabCaseWhereInput { const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null }; if (query.sentFrom) { const from = new Date(query.sentFrom); if (Number.isNaN(from.getTime())) { throw new BadRequestException('Invalid sentFrom date'); } sentAtFilter.gte = from; } if (query.sentTo) { const to = new Date(query.sentTo); if (Number.isNaN(to.getTime())) { throw new BadRequestException('Invalid sentTo date'); } to.setHours(23, 59, 59, 999); sentAtFilter.lte = to; } return { sentAt: sentAtFilter, 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()) : {}), }; } 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 treatmentType = lc.details[0]?.detail.treatmentType ?? null; 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, }, treatmentType, taskProgress: { completed: completedTasks, total: lc.tasks.length, }, }; } private async mapLabCaseDetail( lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>, localeInput?: string | null, ) { const locale = normalizeCatalogLocale(localeInput); const treatmentType = lc.details[0]?.detail.treatmentType ?? null; const link = lc.details[0]; const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))]; const prosthesisLabels = await this.catalogLabels.resolveLabels( CatalogEntityKind.PROSTHESIS_TYPE, prosthesisCodes, locale, ); const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels); return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, isImportant: lc.isImportant, clinic: lc.treatment.organization, patient: lc.treatment.patient, appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null, treatmentType, detail: link ? { id: link.detail.id, treatmentType: link.detail.treatmentType, teeth: normalizeTeeth(link.detail.teeth), comment: link.detail.comment, } : null, toothProsthesis: lc.toothProsthesis.map((row) => ({ treatmentDetailId: row.treatmentDetailId, tooth: row.tooth, prosthesisTypeCode: row.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, sentAt: s.sentAt.toISOString(), })), tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)), tasksByTooth, taskProgress: { completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length, total: lc.tasks.length, }, }; } private groupTasks( tasks: LabCaseTaskWithRelations[], prosthesisLabels: Map, ) { const groups = new Map< string, { treatmentDetailId: string; teeth: string[]; treatmentType: string; prosthesisTypeCode: string; prosthesisTypeLabel: string; tasks: ReturnType[]; } >(); for (const task of tasks) { const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`; const entry = groups.get(key) ?? { treatmentDetailId: task.treatmentDetailId, teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, tasks: [], }; entry.tasks.push(this.mapTask(task, prosthesisLabels)); groups.set(key, entry); } return [...groups.values()]; } private mapTask( task: LabCaseTaskWithRelations, prosthesisLabels: Map, ) { return { id: task.id, treatmentDetailId: task.treatmentDetailId, teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, workflowStepCode: task.workflowStepCode ?? '', stepOrder: task.stepOrder, stepLabel: task.stepLabel, status: task.status, createdAt: task.createdAt.toISOString(), lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null, lastStatusChangedBy: task.lastStatusChangedBy ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } : null, timeline: task.statusEvents.map((event) => ({ id: event.id, fromStatus: event.fromStatus, toStatus: event.toStatus, changedAt: event.changedAt.toISOString(), changedBy: event.changedBy ? { id: event.changedBy.id, name: event.changedBy.name } : null, })), }; } 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 } } }, }); } }