import { HttpStatus, Injectable, } from '@nestjs/common'; import { AppException, ErrorCode } from '../../common/errors'; import { randomUUID } from 'crypto'; import { createReadStream, existsSync, mkdirSync, unlinkSync } from 'fs'; import { join } from 'path'; import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } 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, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto, CreateLabInternalCaseDto, UpdateLabInternalCaseDto } from './dto/cases.dto'; import { generateLabCaseTasks } from './lab-case-task.generator'; 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 { UserNotificationService } from '../notifications/user-notification.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 } }, }, }, partnerClinic: { select: { id: true, name: true } }, lines: { orderBy: [{ sortOrder: 'asc' as const }] }, 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, detailClientKey: 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 { private readonly uploadRoot = join(process.cwd(), 'uploads', 'lab-cases'); constructor( private readonly prisma: PrismaService, private readonly prosthesisCatalog: ProsthesisCatalogService, private readonly catalogLabels: CatalogLabelService, private readonly labCaseActivity: LabCaseActivityService, private readonly userNotifications: UserNotificationService, private readonly labCaseAccess: LabCaseAccessService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); } 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 } }, }, }, partnerClinic: { select: { id: true, name: true } }, tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true, selectionGroupId: true, }, }, }, orderBy: [{ startedAt: 'desc' }, { sentAt: 'desc' }, { id: '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: this.visibleToLabWhere(labOrganizationId), select: { partnerClinic: { select: { id: true, name: true } }, treatment: { select: { organization: { select: { id: true, name: true } }, }, }, }, }), this.prisma.labCaseTask.findMany({ where: { labCase: this.visibleStartedToLabWhere(labOrganizationId), }, select: { prosthesisTypeCode: true }, distinct: ['prosthesisTypeCode'], }), ]); const clinicsById = new Map(); for (const row of clinicRows) { if (row.treatment?.organization) { clinicsById.set(row.treatment.organization.id, row.treatment.organization); } if (row.partnerClinic) { clinicsById.set(row.partnerClinic.id, row.partnerClinic); } } 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, selectionGroupId: 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 AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput), }; } async listLinkedClinics(labOrganizationId: string, actorUserId: string) { await this.assertCanReadCases(actorUserId, labOrganizationId); const [linksA, linksB] = await Promise.all([ this.prisma.organizationLink.findMany({ where: { organizationAId: labOrganizationId, status: LinkStatus.ACTIVE }, include: { organizationB: { select: { id: true, name: true, type: { select: { name: true } } } }, }, }), this.prisma.organizationLink.findMany({ where: { organizationBId: labOrganizationId, status: LinkStatus.ACTIVE }, include: { organizationA: { select: { id: true, name: true, type: { select: { name: true } } } }, }, }), ]); const data = [ ...linksA.map((l) => ({ id: l.organizationB.id, name: l.organizationB.name, type: l.organizationB.type.name, })), ...linksB.map((l) => ({ id: l.organizationA.id, name: l.organizationA.name, type: l.organizationA.type.name, })), ] .filter((o) => o.type === 'CLINIC') .map(({ id, name }) => ({ id, name, active: true })) .sort((a, b) => a.name.localeCompare(b.name)); return { success: true, data }; } async createInternal( dto: CreateLabInternalCaseDto, labOrganizationId: string, actorUserId: string, localeInput?: string | null, ) { await this.assertCanEditCases(actorUserId, labOrganizationId); await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId); const created = await this.prisma.labCase.create({ data: { origin: LabCaseOrigin.LAB_INTERNAL, destinationOrganizationId: labOrganizationId, referringClinicName: dto.referringClinicName?.trim() || null, referringDentistName: dto.referringDentistName?.trim() || null, patientDisplayName: dto.patientDisplayName?.trim() || null, patientDisplayMobile: dto.patientDisplayMobile?.trim() || null, partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null, dueDate: dto.dueDate ? new Date(dto.dueDate) : null, sortOrder: 0, }, include: labCaseListInclude, }); return { success: true, data: await this.mapLabCaseDetail(created, localeInput) }; } async updateInternal( labCaseId: string, dto: UpdateLabInternalCaseDto, labOrganizationId: string, actorUserId: string, localeInput?: string | null, ) { await this.assertCanEditCases(actorUserId, labOrganizationId); const existing = await this.prisma.labCase.findFirst({ where: { id: labCaseId, origin: LabCaseOrigin.LAB_INTERNAL, destinationOrganizationId: labOrganizationId, }, select: { id: true, startedAt: true }, }); if (!existing) { throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } if (existing.startedAt) { throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT); } await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId); const saved = await this.prisma.$transaction(async (tx) => { await tx.labCase.update({ where: { id: labCaseId }, data: { referringClinicName: dto.referringClinicName?.trim() || null, referringDentistName: dto.referringDentistName?.trim() || null, patientDisplayName: dto.patientDisplayName?.trim() || null, patientDisplayMobile: dto.patientDisplayMobile?.trim() || null, partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null, dueDate: dto.dueDate === undefined ? undefined : dto.dueDate ? new Date(dto.dueDate) : null, }, }); if (dto.lines) { const keepIds = dto.lines.map((l) => l.id).filter(Boolean) as string[]; await tx.labCaseLine.deleteMany({ where: { labCaseId, ...(keepIds.length ? { id: { notIn: keepIds } } : {}), }, }); for (const [index, line] of dto.lines.entries()) { if (line.id) { const owned = await tx.labCaseLine.findFirst({ where: { id: line.id, labCaseId }, select: { id: true }, }); if (!owned) { throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.BAD_REQUEST); } } const teeth = normalizeTeeth(line.teeth); const row = line.id ? await tx.labCaseLine.update({ where: { id: line.id }, data: { clientKey: line.clientId, sortOrder: index, teeth, toothSelectionGroups: (line.toothSelectionGroups ?? undefined) as Prisma.InputJsonValue | undefined, comment: line.comment?.trim() || null, }, }) : await tx.labCaseLine.create({ data: { labCaseId, clientKey: line.clientId, sortOrder: index, treatmentType: 'prosthesis', teeth, toothSelectionGroups: (line.toothSelectionGroups ?? undefined) as Prisma.InputJsonValue | undefined, comment: line.comment?.trim() || null, }, }); await tx.labCaseToothProsthesis.deleteMany({ where: { lineId: row.id } }); if (line.toothProsthesis?.length) { for (const tp of line.toothProsthesis) { this.prosthesisCatalog.assertKnownProsthesisType(tp.prosthesisTypeCode); } await tx.labCaseToothProsthesis.createMany({ data: line.toothProsthesis.map((tp) => ({ labCaseId, lineId: row.id, sourceKey: row.id, treatmentType: 'prosthesis', tooth: tp.tooth, prosthesisTypeCode: tp.prosthesisTypeCode, selectionGroupId: tp.selectionGroupId?.trim() || '', })), }); } if (line.attachmentIds) { const lineAttachments = await tx.treatmentDetailAttachment.findMany({ where: { detailClientKey: line.clientId, labCaseLinks: { some: { labCaseId } }, }, select: { id: true }, }); const keep = new Set(line.attachmentIds); const removeIds = lineAttachments.map((a) => a.id).filter((id) => !keep.has(id)); if (removeIds.length) { await tx.labCaseAttachment.deleteMany({ where: { labCaseId, attachmentId: { in: removeIds } }, }); } if (line.attachmentIds.length) { await tx.labCaseAttachment.createMany({ data: line.attachmentIds.map((attachmentId) => ({ labCaseId, attachmentId })), skipDuplicates: true, }); } } } } return tx.labCase.findFirstOrThrow({ where: { id: labCaseId }, include: labCaseListInclude, }); }); return { success: true, data: await this.mapLabCaseDetail(saved, localeInput) }; } async startInternal( labCaseId: string, labOrganizationId: string, actorUserId: string, localeInput?: string | null, ) { await this.assertCanEditCases(actorUserId, labOrganizationId); const existing = await this.prisma.labCase.findFirst({ where: { id: labCaseId, origin: LabCaseOrigin.LAB_INTERNAL, destinationOrganizationId: labOrganizationId, }, include: { lines: true, toothProsthesis: true }, }); if (!existing) { throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } if (existing.startedAt) { throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT); } if (existing.lines.length === 0 || existing.toothProsthesis.length === 0) { throw new AppException(ErrorCode.LAB_CASE_START_INCOMPLETE, HttpStatus.BAD_REQUEST); } const hasClient = Boolean(existing.referringClinicName?.trim()) || Boolean(existing.patientDisplayName?.trim()) || Boolean(existing.partnerClinicOrganizationId); if (!hasClient) { throw new AppException(ErrorCode.LAB_CASE_CLIENT_REQUIRED, HttpStatus.BAD_REQUEST); } await this.prisma.$transaction(async (tx) => { await tx.labCase.update({ where: { id: labCaseId }, data: { startedAt: new Date() }, }); await generateLabCaseTasks(tx, labCaseId, localeInput); }); const refreshed = await this.prisma.labCase.findFirstOrThrow({ where: { id: labCaseId }, include: labCaseListInclude, }); return { success: true, data: await this.mapLabCaseDetail(refreshed, localeInput) }; } async deleteInternalDraft( labCaseId: string, labOrganizationId: string, actorUserId: string, ) { await this.assertCanEditCases(actorUserId, labOrganizationId); const existing = await this.prisma.labCase.findFirst({ where: { id: labCaseId, origin: LabCaseOrigin.LAB_INTERNAL, destinationOrganizationId: labOrganizationId, }, select: { id: true, startedAt: true, attachments: { include: { attachment: { select: { id: true, storagePath: true } } } }, }, }); if (!existing) { throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } if (existing.startedAt) { throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT); } const orphanAttachments = existing.attachments.map((row) => row.attachment); const orphanIds = orphanAttachments.map((row) => row.id); await this.prisma.$transaction(async (tx) => { await tx.labCase.delete({ where: { id: labCaseId } }); if (orphanIds.length > 0) { await tx.treatmentDetailAttachment.deleteMany({ where: { id: { in: orphanIds }, detailId: null }, }); } }); for (const attachment of orphanAttachments) { try { if (attachment.storagePath && existsSync(attachment.storagePath)) { unlinkSync(attachment.storagePath); } } catch { // Best-effort disk cleanup; the draft row is already gone. } } return { success: true }; } async uploadInternalAttachments( labCaseId: string, lineClientKey: string, files: Express.Multer.File[], labOrganizationId: string, actorUserId: string, ) { await this.assertCanEditCases(actorUserId, labOrganizationId); const existing = await this.prisma.labCase.findFirst({ where: { id: labCaseId, origin: LabCaseOrigin.LAB_INTERNAL, destinationOrganizationId: labOrganizationId, }, select: { id: true, startedAt: true }, }); if (!existing) { throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } if (existing.startedAt) { throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT); } if (!lineClientKey?.trim()) { throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST); } if (!files?.length) { throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST); } const orgDir = join(this.uploadRoot, labOrganizationId); 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: { detailClientKey: lineClientKey, fileName: file.originalname, mimeType: file.mimetype || 'application/octet-stream', sizeBytes: file.size, storagePath, }, }); await this.prisma.labCaseAttachment.create({ data: { labCaseId, attachmentId: attachment.id }, }); created.push({ id: attachment.id, fileName: attachment.fileName, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, }); } return { success: true, data: created }; } 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, ...this.visibleToLabWhere(labOrganizationId), }, include: labCaseListInclude, }); if (!labCase) { throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.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: this.visibleToLabWhere(labOrganizationId), }, include: { attachment: { select: { storagePath: true, fileName: true, mimeType: true } }, }, }); if (!link?.attachment) { throw new AppException(ErrorCode.CASE_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND); } if (!existsSync(link.attachment.storagePath)) { throw new AppException(ErrorCode.CASE_ATTACHMENT_MISSING_FILE, HttpStatus.NOT_FOUND); } 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, ...this.visibleToLabWhere(labOrganizationId), }, select: { id: true, isImportant: true }, }); if (!existing) { throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.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, }); void this.userNotifications.notify({ organizationId: labOrganizationId, type: UserNotificationType.CASE_IMPORTANT, href: `/cases?caseId=${encodeURIComponent(labCaseId)}`, actorUserId, payload: { labCaseId }, requiredPermission: 'TAB_CASES_READ', }); } const labCase = await this.prisma.labCase.findFirstOrThrow({ where: { id: labCaseId }, include: labCaseListInclude, }); return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; } async setCaseExternalCode( labCaseId: string, dto: UpdateLabCaseExternalCodeDto, labOrganizationId: string, actorUserId: string, localeInput?: string | null, ) { await this.assertCanEditCases(actorUserId, labOrganizationId); const existing = await this.prisma.labCase.findFirst({ where: { id: labCaseId, ...this.visibleToLabWhere(labOrganizationId), }, select: { id: true }, }); if (!existing) { throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } const externalCode = dto.externalCode == null || !String(dto.externalCode).trim() ? null : String(dto.externalCode).trim().slice(0, 64); await this.prisma.labCase.update({ where: { id: labCaseId }, data: { externalCode }, }); 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: this.visibleStartedToLabWhere(labOrganizationId), }, select: { id: true }, }); if (!task) { throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.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 AppException(ErrorCode.TASK_ASSIGNEE_INVALID, HttpStatus.BAD_REQUEST); } } await this.prisma.labCaseTask.update({ where: { id: taskId }, data: { assigneeUserId, assignedAt: assigneeUserId ? new Date() : null, }, }); if (assigneeUserId) { await this.labCaseActivity.record({ labCaseId, type: LabCaseActivityType.TASK_ASSIGNED, actorUserId, payload: { taskId, assigneeUserId }, }); void this.userNotifications.notify({ organizationId: labOrganizationId, type: UserNotificationType.TASK_ASSIGNED, href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`, actorUserId, payload: { labCaseId, taskId, assigneeUserId }, recipientUserIds: [assigneeUserId], }); } 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 AppException(ErrorCode.INVALID_SENT_FROM, HttpStatus.BAD_REQUEST); } sentAtFilter.gte = from; } if (query.sentTo) { const to = new Date(query.sentTo); if (Number.isNaN(to.getTime())) { throw new AppException(ErrorCode.INVALID_SENT_TO, HttpStatus.BAD_REQUEST); } to.setHours(23, 59, 59, 999); sentAtFilter.lte = to; } const received: Prisma.LabCaseWhereInput = { sentAt: sentAtFilter, sends: { some: { organizationId: labOrganizationId } }, ...(query.clinicOrganizationId ? { treatment: { organizationId: query.clinicOrganizationId } } : {}), }; const ownedInternal: Prisma.LabCaseWhereInput = { origin: LabCaseOrigin.LAB_INTERNAL, destinationOrganizationId: labOrganizationId, ...(query.clinicOrganizationId ? { OR: [ { partnerClinicOrganizationId: query.clinicOrganizationId }, ], } : {}), }; if (query.sentFrom || query.sentTo) { ownedInternal.startedAt = { ...sentAtFilter }; } return { AND: [ { OR: [received, ownedInternal] }, ...(query.prosthesisTypeCode ? [ { tasks: { some: { prosthesisTypeCode: query.prosthesisTypeCode }, }, } satisfies Prisma.LabCaseWhereInput, ] : []), ...(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' } }, }, }, { referringClinicName: { contains: q, mode: 'insensitive' } }, { referringDentistName: { contains: q, mode: 'insensitive' } }, { patientDisplayName: { contains: q, mode: 'insensitive' } }, { patientDisplayMobile: { contains: q, mode: 'insensitive' } }, ]; const normalized = normalizeMobile(q); if (normalized) { orConditions.push({ treatment: { patient: { mobile: normalized } }, }); } return { OR: orConditions }; } private resolveClinicAndPatient(lc: { origin?: LabCaseOrigin; referringClinicName?: string | null; patientDisplayName?: string | null; patientDisplayMobile?: string | null; partnerClinic?: { id: string; name: string } | null; treatment?: { organization: { id: string; name: string }; patient: { id: string; firstName: string; lastName: string; mobile: string }; } | null; }) { const clinic = lc.treatment?.organization ?? lc.partnerClinic ?? { id: '', name: lc.referringClinicName?.trim() || '', }; const displayName = lc.patientDisplayName?.trim() || ''; const [firstName, ...rest] = displayName.split(/\s+/); const patient = lc.treatment?.patient ?? { id: '', firstName: firstName || displayName || clinic.name || '—', lastName: rest.join(' '), mobile: lc.patientDisplayMobile?.trim() || '', }; return { clinic, patient }; } private mapLabCaseListItem(lc: { id: string; sentAt: Date | null; startedAt?: Date | null; dueDate: Date | null; isImportant: boolean; origin?: LabCaseOrigin; referringClinicName?: string | null; patientDisplayName?: string | null; patientDisplayMobile?: string | null; partnerClinic?: { id: string; name: string } | null; treatment: { organization: { id: string; name: string }; patient: { id: string; firstName: string; lastName: string; mobile: string }; } | null; 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; const { clinic, patient } = this.resolveClinicAndPatient(lc); return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, startedAt: lc.startedAt?.toISOString() ?? null, origin: lc.origin ?? LabCaseOrigin.CLINIC_DISPATCH, dueDate: lc.dueDate?.toISOString() ?? null, isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, clinic, patient, 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 ?? lc.lines[0]?.treatmentType ?? null; const link = lc.details[0]; const { clinic, patient } = this.resolveClinicAndPatient(lc); 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, externalCode: lc.externalCode ?? null, origin: lc.origin, startedAt: lc.startedAt?.toISOString() ?? null, referringClinicName: lc.referringClinicName, referringDentistName: lc.referringDentistName, patientDisplayName: lc.patientDisplayName, patientDisplayMobile: lc.patientDisplayMobile, partnerClinicOrganizationId: lc.partnerClinicOrganizationId, shareUrl, clinic, patient, appointmentStartAt: lc.treatment?.appointment?.startAt?.toISOString() ?? null, treatmentType, detail: link?.detail ? { id: link.detail.id, treatmentType: link.detail.treatmentType, teeth: normalizeTeeth(link.detail.teeth), comment: link.detail.comment, } : lc.lines[0] ? { id: lc.lines[0].id, treatmentType: lc.lines[0].treatmentType, teeth: normalizeTeeth(lc.lines[0].teeth), comment: lc.lines[0].comment, } : null, lines: lc.lines.map((line) => ({ id: line.id, clientId: line.clientKey ?? line.id, treatmentType: line.treatmentType, teeth: normalizeTeeth(line.teeth), toothSelectionGroups: line.toothSelectionGroups, comment: line.comment, })), toothProsthesis: lc.toothProsthesis.map((row) => ({ treatmentDetailId: row.treatmentDetailId, lineId: row.lineId, tooth: row.tooth, prosthesisTypeCode: row.prosthesisTypeCode, selectionGroupId: row.selectionGroupId?.trim() || undefined, })), 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(), detailClientKey: row.attachment.detailClientKey, })), 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; selectionGroupId?: string; }>, ): Array<{ prosthesisTypeCode: string; teeth: string[]; connected?: boolean; selectionGroupId?: string; }> { const prosthesisByGroup = new Map< string, { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string } >(); for (const task of tasks) { if (!task.prosthesisTypeCode) continue; const selectionGroupId = task.selectionGroupId ?? ''; const key = `${selectionGroupId}::${task.prosthesisTypeCode}`; const teeth = normalizeTaskTeeth(task.teeth); const entry = prosthesisByGroup.get(key) ?? { prosthesisTypeCode: task.prosthesisTypeCode, teeth: [], selectionGroupId, }; entry.teeth.push(...teeth); prosthesisByGroup.set(key, entry); } return [...prosthesisByGroup.values()] .map((group) => ({ prosthesisTypeCode: group.prosthesisTypeCode, teeth: [...new Set(group.teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }), ), selectionGroupId: group.selectionGroupId || undefined, connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1, })) .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; selectionGroupId: string; tasks: ReturnType[]; } >(); for (const task of tasks) { const selectionGroupId = task.selectionGroupId ?? ''; const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${selectionGroupId}:${task.prosthesisTypeCode}`; const entry = groups.get(key) ?? { treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '', teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, selectionGroupId, tasks: [], }; entry.tasks.push(this.mapTask(task, prosthesisLabels)); groups.set(key, entry); } return [...groups.values()].map((group) => ({ treatmentDetailId: group.treatmentDetailId, teeth: [...new Set(group.teeth)], treatmentType: group.treatmentType, prosthesisTypeCode: group.prosthesisTypeCode, prosthesisTypeLabel: group.prosthesisTypeLabel, selectionGroupId: group.selectionGroupId || undefined, connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1, tasks: group.tasks, })); } private mapTask( task: LabCaseTaskWithRelations, prosthesisLabels: Map, ) { return { id: task.id, treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '', 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 visibleToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput { return { OR: [ { sentAt: { not: null }, sends: { some: { organizationId: labOrganizationId } }, }, { origin: LabCaseOrigin.LAB_INTERNAL, destinationOrganizationId: labOrganizationId, }, ], }; } private visibleStartedToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput { return { OR: [ { sentAt: { not: null }, sends: { some: { organizationId: labOrganizationId } }, }, { origin: LabCaseOrigin.LAB_INTERNAL, destinationOrganizationId: labOrganizationId, startedAt: { not: null }, }, ], }; } private async assertPartnerClinic( partnerId: string | null | undefined, labOrganizationId: string, ) { if (!partnerId) return; const org = await this.prisma.organization.findUnique({ where: { id: partnerId }, select: { id: true, type: { select: { name: true } } }, }); if (!org || org.type.name !== 'CLINIC') { throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST); } const [linksA, linksB] = await Promise.all([ this.prisma.organizationLink.findFirst({ where: { organizationAId: labOrganizationId, organizationBId: partnerId, status: LinkStatus.ACTIVE, }, select: { id: true }, }), this.prisma.organizationLink.findFirst({ where: { organizationAId: partnerId, organizationBId: labOrganizationId, status: LinkStatus.ACTIVE, }, select: { id: true }, }), ]); if (!linksA && !linksB) { throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST); } } private async assertCanReadCases(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } 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 AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN); } private async assertCanEditCases(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if (m.isOwner) return; const names = m.permissions.map((p) => p.permission.name); if (names.includes('TAB_CASES_EDIT')) { return; } throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN); } private async getMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId, isActive: true }, include: { permissions: { include: { permission: true } } }, }); } }