import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { LabTaskStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto'; 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: [ { tooth: 'asc' as const }, { treatmentType: 'asc' as const }, { stepOrder: 'asc' as const }, ], include: { assignee: { select: { id: true, name: true, email: true } }, }, }, } satisfies Prisma.LabCaseInclude; @Injectable() export class CasesService { constructor( private readonly prisma: PrismaService, private readonly treatmentCatalog: TreatmentCatalogService, ) {} 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: Prisma.LabCaseWhereInput = { sentAt: { not: null }, 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()) : {}), }; 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 getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) { 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: this.mapLabCaseDetail(labCase) }; } async updateTask( labCaseId: string, taskId: string, dto: UpdateLabCaseTaskDto, labOrganizationId: string, actorUserId: string, ) { await this.assertCanEditCases(actorUserId, labOrganizationId); const task = await this.prisma.labCaseTask.findFirst({ where: { id: taskId, labCaseId, labCase: { sentAt: { not: null }, sends: { some: { organizationId: labOrganizationId } }, }, }, }); if (!task) { throw new NotFoundException('Task not found'); } if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) { await this.ensureLabMember(dto.assigneeUserId, labOrganizationId); } const updated = await this.prisma.labCaseTask.update({ where: { id: taskId }, data: { ...(dto.assigneeUserId !== undefined ? { assigneeUserId: dto.assigneeUserId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), }, include: { assignee: { select: { id: true, name: true, email: true } }, }, }); return { success: true, data: this.mapTask(updated) }; } async listAssignableMembers(labOrganizationId: string, actorUserId: string) { await this.assertCanReadCases(actorUserId, labOrganizationId); const memberships = await this.prisma.membership.findMany({ where: { organizationId: labOrganizationId, isActive: true }, include: { user: { select: { id: true, name: true, email: true } } }, orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], }); return { success: true, data: memberships.map((m) => ({ userId: m.user.id, name: m.user.name, email: m.user.email, isOwner: m.isOwner, })), }; } 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 treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))]; 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, }, treatmentTypes, taskProgress: { completed: completedTasks, total: lc.tasks.length, }, }; } private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) { const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))]; const tasksByTooth = this.groupTasksByTooth(lc.tasks); return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, labComment: lc.labComment, clinic: lc.treatment.organization, patient: lc.treatment.patient, appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null, treatmentTypes, details: lc.details.map((link) => ({ id: link.detail.id, treatmentType: link.detail.treatmentType, teeth: normalizeTeeth(link.detail.teeth), comment: link.detail.comment, })), sends: lc.sends.map((s) => ({ organizationId: s.organizationId, organizationName: s.organization.name, sentAt: s.sentAt.toISOString(), })), tasks: lc.tasks.map((t) => this.mapTask(t)), tasksByTooth, taskProgress: { completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length, total: lc.tasks.length, }, }; } private groupTasksByTooth( tasks: Array<{ id: string; tooth: string; treatmentType: string; stepOrder: number; stepLabel: string; status: LabTaskStatus; assigneeUserId: string | null; assignee: { id: string; name: string; email: string } | null; }>, ) { const groups = new Map< string, { tooth: string; treatmentType: string; tasks: ReturnType[]; } >(); for (const task of tasks) { const key = `${task.tooth}:${task.treatmentType}`; const entry = groups.get(key) ?? { tooth: task.tooth, treatmentType: task.treatmentType, tasks: [], }; entry.tasks.push(this.mapTask(task)); groups.set(key, entry); } return [...groups.values()]; } private mapTask(task: { id: string; tooth: string; treatmentType: string; stepOrder: number; stepLabel: string; status: LabTaskStatus; assigneeUserId: string | null; assignee: { id: string; name: string; email: string } | null; }) { return { id: task.id, tooth: task.tooth, treatmentType: task.treatmentType, stepOrder: task.stepOrder, stepLabel: task.stepLabel, status: task.status, assigneeUserId: task.assigneeUserId, assignee: task.assignee ? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email } : null, }; } private async ensureLabMember(userId: string, labOrganizationId: string) { const membership = await this.prisma.membership.findFirst({ where: { userId, organizationId: labOrganizationId, isActive: true }, select: { id: true }, }); if (!membership) { throw new BadRequestException('Assignee must be an active member of this lab'); } } 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 } } }, }); } }