1424 lines
45 KiB
TypeScript
1424 lines
45 KiB
TypeScript
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<string, { id: string; name: string }>();
|
|
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<string, string>,
|
|
) {
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
treatmentDetailId: string;
|
|
teeth: string[];
|
|
treatmentType: string;
|
|
prosthesisTypeCode: string;
|
|
prosthesisTypeLabel: string;
|
|
selectionGroupId: string;
|
|
tasks: ReturnType<CasesService['mapTask']>[];
|
|
}
|
|
>();
|
|
|
|
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<string, string>,
|
|
) {
|
|
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 } } },
|
|
});
|
|
}
|
|
}
|