import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { createReadStream, existsSync } from 'fs'; import { CatalogEntityKind, LabCaseActivityType, 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 { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; import { isLabCaseOverdue, } from '../../common/lab-case-due-date'; import { normalizeTaskTeeth } from './lab-case-task.util'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; import { LabCaseAccessService } from './lab-case-access.service'; 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 } }, assignee: { 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 } }; assignee: { select: { id: true; name: true } }; statusEvents: { include: { changedBy: { select: { id: true; name: true } } }; }; }; }>; @Injectable() export class CasesService { constructor( private readonly prisma: PrismaService, private readonly prosthesisCatalog: ProsthesisCatalogService, private readonly catalogLabels: CatalogLabelService, private readonly labCaseActivity: LabCaseActivityService, private readonly labCaseAccess: LabCaseAccessService, ) {} 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.prosthesisTypeCode) { this.prosthesisCatalog.assertKnownProsthesisType(query.prosthesisTypeCode); } 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 } }, }, }, tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } }, }, orderBy: [{ sentAt: 'desc' }], skip, take: limit, }), this.prisma.labCase.count({ where }), ]); const unreadCaseIds = await this.labCaseActivity.unreadCaseIdsInBatch( actorUserId, labOrganizationId, items.map((lc) => lc.id), LAB_CASES_TAB_ACTIVITY_TYPES, 'LAB', ); return { success: true, data: { items: items.map((lc) => ({ ...this.mapLabCaseListItem(lc), hasUnread: unreadCaseIds.has(lc.id), })), pagination: { page, limit, total, totalPages: Math.max(1, Math.ceil(total / limit)), }, }, }; } async listFilterOptions(labOrganizationId: string, actorUserId: string) { await this.assertCanReadCases(actorUserId, labOrganizationId); const [clinicRows, taskRows] = await Promise.all([ this.prisma.labCase.findMany({ where: { sentAt: { not: null }, sends: { some: { organizationId: labOrganizationId } }, }, select: { treatment: { select: { organization: { select: { id: true, name: true } }, }, }, }, }), this.prisma.labCaseTask.findMany({ where: { labCase: { sentAt: { not: null }, sends: { some: { organizationId: labOrganizationId } }, }, }, select: { prosthesisTypeCode: true }, distinct: ['prosthesisTypeCode'], }), ]); const clinicsById = new Map(); for (const row of clinicRows) { clinicsById.set(row.treatment.organization.id, row.treatment.organization); } const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode)); const catalog = await this.prosthesisCatalog.list(); const prosthesisTypes = catalog .filter((entry) => typeCodes.has(entry.code)) .map((entry) => ({ code: entry.code })); return { success: true, data: { clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)), prosthesisTypes, }, }; } /** Cases exchanged between one clinic and one lab (Organizations connection history). */ async listBetweenOrganizations( clinicOrganizationId: string, labOrganizationId: string, query: ListLabCasesDto, ) { if (query.prosthesisTypeCode) { this.prosthesisCatalog.assertKnownProsthesisType(query.prosthesisTypeCode); } 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 } }, }, }, tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: 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, isImportant: true }, }); if (!existing) { throw new NotFoundException('Case not found'); } await this.prisma.labCase.update({ where: { id: labCaseId }, data: { isImportant: dto.isImportant }, }); if (dto.isImportant && !existing.isImportant) { await this.labCaseActivity.record({ labCaseId, type: LabCaseActivityType.CASE_IMPORTANT, actorUserId, }); } const labCase = await this.prisma.labCase.findFirstOrThrow({ where: { id: labCaseId }, include: labCaseListInclude, }); return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; } async listAssignableStaff(labOrganizationId: string, actorUserId: string) { await this.assertCanEditCases(actorUserId, labOrganizationId); const memberships = await this.prisma.membership.findMany({ where: { organizationId: labOrganizationId, OR: [{ isOwner: true }, { isActive: true }], }, include: { permissions: { include: { permission: true } }, organization: { include: { type: true, plan: true } }, user: { select: { id: true, name: true } }, }, }); const staff = memberships .filter((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT')) .map((m) => ({ id: m.user.id, name: m.user.name, })) .sort((a, b) => a.name.localeCompare(b.name)); return { success: true, data: staff }; } async assignTask( labCaseId: string, taskId: string, dto: AssignLabCaseTaskDto, labOrganizationId: string, actorUserId: string, localeInput?: string | null, ) { await this.assertCanEditCases(actorUserId, labOrganizationId); const task = await this.prisma.labCaseTask.findFirst({ where: { id: taskId, labCaseId, labCase: { sentAt: { not: null }, sends: { some: { organizationId: labOrganizationId } }, }, }, select: { id: true }, }); if (!task) { throw new NotFoundException('Task not found'); } const assigneeUserId = dto.assigneeUserId ?? null; if (assigneeUserId) { const memberships = await this.prisma.membership.findMany({ where: { organizationId: labOrganizationId, userId: assigneeUserId, OR: [{ isOwner: true }, { isActive: true }], }, include: { permissions: { include: { permission: true } }, organization: { include: { type: true, plan: true } }, }, }); const canReceive = memberships.some((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT')); if (!canReceive) { throw new BadRequestException('Selected user cannot be assigned tasks'); } } await this.prisma.labCaseTask.update({ where: { id: taskId }, data: { assigneeUserId, assignedAt: assigneeUserId ? new Date() : null, }, }); 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.prosthesisTypeCode ? { tasks: { some: { prosthesisTypeCode: query.prosthesisTypeCode }, }, } : {}), ...(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; dueDate: Date | null; isImportant: boolean; treatment: { organization: { id: string; name: string }; patient: { id: string; firstName: string; lastName: string; mobile: string }; }; tasks: Array<{ id: string; status: LabTaskStatus; prosthesisTypeCode: string; teeth: Prisma.JsonValue; }>; }) { const prosthesisGroups = this.buildProsthesisGroupsFromTasks(lc.tasks); const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length; return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, dueDate: lc.dueDate?.toISOString() ?? null, isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, 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, }, prosthesisGroups, 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); const accessToken = lc.sentAt ? await this.labCaseAccess.ensureAccessToken(lc.id) : null; const shareUrl = accessToken ? this.labCaseAccess.buildShareUrl(accessToken, locale) : null; return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, dueDate: lc.dueDate?.toISOString() ?? null, isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, shareUrl, 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 buildProsthesisGroupsFromTasks( tasks: Array<{ prosthesisTypeCode: string; teeth: Prisma.JsonValue }>, ): Array<{ prosthesisTypeCode: string; teeth: string[] }> { const prosthesisByCode = new Map(); for (const task of tasks) { if (!task.prosthesisTypeCode) continue; const teeth = normalizeTaskTeeth(task.teeth); const list = prosthesisByCode.get(task.prosthesisTypeCode) ?? []; list.push(...teeth); prosthesisByCode.set(task.prosthesisTypeCode, list); } return [...prosthesisByCode.entries()] .map(([prosthesisTypeCode, teeth]) => ({ prosthesisTypeCode, teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }), ), })) .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); } 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, assignee: task.assignee ? { id: task.assignee.id, name: task.assignee.name } : null, assignedAt: task.assignedAt?.toISOString() ?? null, 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 } } }, }); } }