improvement: v1 standalone treatment/case creation made possible.

This commit is contained in:
2026-08-19 15:05:58 +03:30
parent e5c39c6b7e
commit 8bfa8c88fe
39 changed files with 3296 additions and 387 deletions

View File

@@ -4,17 +4,30 @@ import {
Get,
Param,
Patch,
Post,
Put,
Query,
Req,
Res,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CasesService } from './cases.service';
import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto';
import {
ListLabCasesDto,
UpdateLabCaseImportantDto,
UpdateLabCaseExternalCodeDto,
AssignLabCaseTaskDto,
CreateLabInternalCaseDto,
UpdateLabInternalCaseDto,
} from './dto/cases.dto';
@ApiTags('cases')
@ApiBearerAuth('JWT-auth')
@@ -30,6 +43,13 @@ export class CasesController {
return this.casesService.list(organizationId, req.user.id, query);
}
@Post()
@ApiOperation({ summary: 'Create a lab-origin draft case (TAB_CASES_EDIT)' })
create(@Body() dto: CreateLabInternalCaseDto, @Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.createInternal(dto, organizationId, req.user.id, req.user.language);
}
@Get('assignable-staff')
@ApiOperation({ summary: 'Staff who can be assigned lab tasks' })
listAssignableStaff(@Req() req) {
@@ -44,6 +64,13 @@ export class CasesController {
return this.casesService.listFilterOptions(organizationId, req.user.id);
}
@Get('linked-clinics')
@ApiOperation({ summary: 'Active linked clinic organizations for lab-origin cases' })
listLinkedClinics(@Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.listLinkedClinics(organizationId, req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
getOne(@Param('id') id: string, @Req() req) {
@@ -51,6 +78,44 @@ export class CasesController {
return this.casesService.getOne(id, organizationId, req.user.id, req.user.language);
}
@Put(':id')
@ApiOperation({ summary: 'Update a lab-origin draft case and its lines' })
update(@Param('id') id: string, @Body() dto: UpdateLabInternalCaseDto, @Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.updateInternal(id, dto, organizationId, req.user.id, req.user.language);
}
@Post(':id/start')
@ApiOperation({ summary: 'Start a lab-origin draft (generate tasks, no clinic notify)' })
start(@Param('id') id: string, @Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.startInternal(id, organizationId, req.user.id, req.user.language);
}
@Post(':id/lines/:lineClientKey/attachments')
@ApiOperation({ summary: 'Upload attachments for a lab-origin draft line' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FilesInterceptor('files', 20, {
storage: memoryStorage(),
}),
)
uploadAttachments(
@Param('id') id: string,
@Param('lineClientKey') lineClientKey: string,
@UploadedFiles() files: Express.Multer.File[],
@Req() req,
) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.uploadInternalAttachments(
id,
lineClientKey,
files,
organizationId,
req.user.id,
);
}
@Get(':id/attachments/:attachmentId/file')
@ApiOperation({ summary: 'Download an attachment shared with this lab case' })
async downloadAttachment(

View File

@@ -3,8 +3,10 @@ import {
Injectable,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { createReadStream, existsSync } from 'fs';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
import { randomUUID } from 'crypto';
import { createReadStream, existsSync, mkdirSync } 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 {
@@ -13,7 +15,8 @@ import {
} from '../catalog/catalog-label.service';
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto';
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';
@@ -32,6 +35,8 @@ const labCaseListInclude = {
appointment: { select: { startAt: true } },
},
},
partnerClinic: { select: { id: true, name: true } },
lines: { orderBy: [{ sortOrder: 'asc' as const }] },
details: {
include: {
detail: {
@@ -73,6 +78,7 @@ const labCaseListInclude = {
mimeType: true,
sizeBytes: true,
createdAt: true,
detailClientKey: true,
},
},
},
@@ -91,6 +97,8 @@ type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
@Injectable()
export class CasesService {
private readonly uploadRoot = join(process.cwd(), 'uploads', 'lab-cases');
constructor(
private readonly prisma: PrismaService,
private readonly prosthesisCatalog: ProsthesisCatalogService,
@@ -130,6 +138,7 @@ export class CasesService {
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
},
},
partnerClinic: { select: { id: true, name: true } },
tasks: {
select: {
id: true,
@@ -140,7 +149,7 @@ export class CasesService {
},
},
},
orderBy: [{ sentAt: 'desc' }],
orderBy: [{ startedAt: 'desc' }, { sentAt: 'desc' }, { id: 'desc' }],
skip,
take: limit,
}),
@@ -177,11 +186,9 @@ export class CasesService {
const [clinicRows, taskRows] = await Promise.all([
this.prisma.labCase.findMany({
where: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
where: this.visibleToLabWhere(labOrganizationId),
select: {
partnerClinic: { select: { id: true, name: true } },
treatment: {
select: {
organization: { select: { id: true, name: true } },
@@ -191,10 +198,7 @@ export class CasesService {
}),
this.prisma.labCaseTask.findMany({
where: {
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
labCase: this.visibleStartedToLabWhere(labOrganizationId),
},
select: { prosthesisTypeCode: true },
distinct: ['prosthesisTypeCode'],
@@ -203,7 +207,12 @@ export class CasesService {
const clinicsById = new Map<string, { id: string; name: string }>();
for (const row of clinicRows) {
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
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));
@@ -308,6 +317,322 @@ export class CasesService {
};
}
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 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,
@@ -319,8 +644,7 @@ export class CasesService {
const labCase = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
...this.visibleToLabWhere(labOrganizationId),
},
include: labCaseListInclude,
});
@@ -344,10 +668,7 @@ export class CasesService {
where: {
labCaseId,
attachmentId,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
labCase: this.visibleToLabWhere(labOrganizationId),
},
include: {
attachment: { select: { storagePath: true, fileName: true, mimeType: true } },
@@ -381,8 +702,7 @@ export class CasesService {
const existing = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
...this.visibleToLabWhere(labOrganizationId),
},
select: { id: true, isImportant: true },
});
@@ -432,8 +752,7 @@ export class CasesService {
const existing = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
...this.visibleToLabWhere(labOrganizationId),
},
select: { id: true },
});
@@ -500,10 +819,7 @@ export class CasesService {
where: {
id: taskId,
labCaseId,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
labCase: this.visibleStartedToLabWhere(labOrganizationId),
},
select: { id: true },
});
@@ -590,20 +906,44 @@ export class CasesService {
sentAtFilter.lte = to;
}
return {
const received: Prisma.LabCaseWhereInput = {
sentAt: sentAtFilter,
sends: { some: { organizationId: labOrganizationId } },
...(query.clinicOrganizationId
? { treatment: { organizationId: query.clinicOrganizationId } }
: {}),
...(query.prosthesisTypeCode
};
const ownedInternal: Prisma.LabCaseWhereInput = {
origin: LabCaseOrigin.LAB_INTERNAL,
destinationOrganizationId: labOrganizationId,
...(query.clinicOrganizationId
? {
tasks: {
some: { prosthesisTypeCode: query.prosthesisTypeCode },
},
OR: [
{ partnerClinicOrganizationId: query.clinicOrganizationId },
],
}
: {}),
...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}),
};
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())] : []),
],
};
}
@@ -624,6 +964,10 @@ export class CasesService {
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);
@@ -636,15 +980,49 @@ export class CasesService {
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;
@@ -654,20 +1032,18 @@ export class CasesService {
}) {
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: lc.treatment.organization,
patient: {
id: lc.treatment.patient.id,
firstName: lc.treatment.patient.firstName,
lastName: lc.treatment.patient.lastName,
mobile: lc.treatment.patient.mobile,
},
clinic,
patient,
prosthesisGroups,
taskProgress: {
completed: completedTasks,
@@ -681,8 +1057,10 @@ export class CasesService {
localeInput?: string | null,
) {
const locale = normalizeCatalogLocale(localeInput);
const treatmentType = lc.details[0]?.detail.treatmentType ?? null;
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,
@@ -704,21 +1082,44 @@ export class CasesService {
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: lc.treatment.organization,
patient: lc.treatment.patient,
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
clinic,
patient,
appointmentStartAt: lc.treatment?.appointment?.startAt?.toISOString() ?? null,
treatmentType,
detail: link
detail: link?.detail
? {
id: link.detail.id,
treatmentType: link.detail.treatmentType,
teeth: normalizeTeeth(link.detail.teeth),
comment: link.detail.comment,
}
: null,
: 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,
@@ -729,6 +1130,7 @@ export class CasesService {
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,
@@ -806,9 +1208,9 @@ export class CasesService {
for (const task of tasks) {
const selectionGroupId = task.selectionGroupId ?? '';
const key = `${task.treatmentDetailId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
treatmentDetailId: task.treatmentDetailId,
treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '',
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
@@ -839,7 +1241,7 @@ export class CasesService {
) {
return {
id: task.id,
treatmentDetailId: task.treatmentDetailId,
treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '',
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
@@ -870,6 +1272,72 @@ export class CasesService {
};
}
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) {

View File

@@ -1,5 +1,18 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export class UpdateLabCaseImportantDto {
@IsBoolean()
@@ -53,3 +66,105 @@ export class ListLabCasesDto {
@Max(100)
limit = 20;
}
export class ToothSelectionGroupDto {
@IsString()
@MaxLength(64)
groupId: string;
@IsString()
kind: string;
@IsArray()
@IsString({ each: true })
teeth: string[];
}
export class LabInternalToothProsthesisDto {
@IsString()
@MaxLength(8)
tooth: string;
@IsString()
@MaxLength(64)
prosthesisTypeCode: string;
@IsOptional()
@IsString()
@MaxLength(64)
selectionGroupId?: string;
}
export class LabInternalCaseLineDto {
@IsString()
@MaxLength(64)
clientId: string;
@IsOptional()
@IsUUID()
id?: string;
@IsArray()
@IsString({ each: true })
teeth: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ToothSelectionGroupDto)
toothSelectionGroups?: ToothSelectionGroupDto[];
@IsOptional()
@IsString()
@MaxLength(5000)
comment?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => LabInternalToothProsthesisDto)
toothProsthesis?: LabInternalToothProsthesisDto[];
@IsOptional()
@IsArray()
@IsUUID(undefined, { each: true })
attachmentIds?: string[];
}
export class CreateLabInternalCaseDto {
@IsOptional()
@IsString()
@MaxLength(200)
referringClinicName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
referringDentistName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
patientDisplayName?: string;
@IsOptional()
@IsString()
@MaxLength(32)
patientDisplayMobile?: string;
@IsOptional()
@IsUUID()
partnerClinicOrganizationId?: string | null;
@IsOptional()
@IsDateString()
dueDate?: string | null;
}
export class UpdateLabInternalCaseDto extends CreateLabInternalCaseDto {
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => LabInternalCaseLineDto)
lines?: LabInternalCaseLineDto[];
}

View File

@@ -128,9 +128,16 @@ export class LabCaseAccessService {
dueDate: labCase.dueDate?.toISOString() ?? null,
isOverdue: isLabCaseOverdue(labCase.dueDate, labCase.tasks),
isImportant: labCase.isImportant,
clinic: labCase.treatment.organization,
clinic: labCase.treatment?.organization ?? {
id: '',
name: '',
},
lab: labCase.sends[0]?.organization ?? null,
patient: labCase.treatment.patient,
patient: labCase.treatment?.patient ?? {
id: '',
firstName: '',
lastName: '',
},
prosthesisGroups,
},
};
@@ -240,7 +247,7 @@ export class LabCaseAccessService {
actorUserId: string,
organizationId: string,
): Promise<ResolvedAccess> {
const clinicOrgId = labCase.treatment.organization.id;
const clinicOrgId = labCase.treatment?.organization.id;
const labOrgId =
labCase.destinationOrganizationId ?? labCase.sends[0]?.organizationId ?? null;
@@ -266,13 +273,13 @@ export class LabCaseAccessService {
return { kind: 'denied' };
}
if (organizationId === clinicOrgId) {
if (clinicOrgId && organizationId === clinicOrgId) {
const membership = await this.getMembership(actorUserId, clinicOrgId);
if (!membership) return { kind: 'denied' };
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
return { kind: 'denied' };
}
if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
if (!labCase.treatment || !isActorTreatmentProvider(labCase.treatment, actorUserId)) {
return { kind: 'denied' };
}
return {

View File

@@ -8,7 +8,9 @@ import { generateLabCaseTasks } from './lab-case-task.generator';
function buildMockTx(options: {
existingCount?: number;
toothProsthesisRows: Array<{
treatmentDetailId: string;
treatmentDetailId?: string | null;
lineId?: string | null;
sourceKey?: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
@@ -33,15 +35,27 @@ function buildMockTx(options: {
labCaseToothProsthesis: {
findMany: jest.fn().mockResolvedValue(
options.toothProsthesisRows.map((row) => ({
treatmentDetailId: row.treatmentDetailId,
treatmentDetailId: row.treatmentDetailId ?? null,
lineId: row.lineId ?? null,
sourceKey: row.sourceKey ?? row.treatmentDetailId ?? row.lineId ?? '',
treatmentType: row.treatmentType ?? 'prosthesis',
tooth: row.tooth,
prosthesisTypeCode: row.prosthesisTypeCode,
selectionGroupId: row.selectionGroupId ?? '',
detail: {
id: row.treatmentDetailId,
treatmentType: row.treatmentType ?? 'prosthesis',
toothSelectionGroups: null,
},
detail: row.treatmentDetailId
? {
id: row.treatmentDetailId,
treatmentType: row.treatmentType ?? 'prosthesis',
toothSelectionGroups: null,
}
: null,
line: row.lineId
? {
id: row.lineId,
treatmentType: row.treatmentType ?? 'prosthesis',
toothSelectionGroups: null,
}
: null,
})),
),
},
@@ -219,4 +233,34 @@ describe('generateLabCaseTasks', () => {
expect(count).toBe(0);
expect(tx.labCaseTask.createMany).not.toHaveBeenCalled();
});
it('creates tasks from lab-origin lines using sourceKey and lineId', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
lineId: 'line-1',
sourceKey: 'line-1',
tooth: '11',
prosthesisTypeCode: 'pfm_crown',
},
{
lineId: 'line-1',
sourceKey: 'line-1',
tooth: '21',
prosthesisTypeCode: 'pfm_crown',
},
],
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-internal-1', 'en');
expect(count).toBe(pfmSteps.length);
expect(created[0]).toMatchObject({
lineId: 'line-1',
sourceKey: 'line-1',
treatmentDetailId: null,
teeth: ['11', '21'],
});
});
});

View File

@@ -19,6 +19,7 @@ export async function generateLabCaseTasks(
where: { labCaseId },
include: {
detail: { select: { id: true, treatmentType: true, toothSelectionGroups: true } },
line: { select: { id: true, treatmentType: true, toothSelectionGroups: true } },
},
});
@@ -64,7 +65,9 @@ export async function generateLabCaseTasks(
const groups = new Map<
string,
{
treatmentDetailId: string;
sourceKey: string;
treatmentDetailId: string | null;
lineId: string | null;
treatmentType: string;
prosthesisTypeCode: string;
selectionGroupId: string;
@@ -73,11 +76,23 @@ export async function generateLabCaseTasks(
>();
for (const row of toothProsthesisRows) {
const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`;
const sourceKey =
row.sourceKey ||
row.treatmentDetailId ||
row.lineId ||
'';
if (!sourceKey) continue;
const key = `${sourceKey}::${row.prosthesisTypeCode}`;
const selectionGroupId = row.selectionGroupId?.trim() || '';
const group = groups.get(key) ?? {
sourceKey,
treatmentDetailId: row.treatmentDetailId,
treatmentType: row.detail.treatmentType,
lineId: row.lineId,
treatmentType:
row.detail?.treatmentType ??
row.line?.treatmentType ??
row.treatmentType ??
'prosthesis',
prosthesisTypeCode: row.prosthesisTypeCode,
selectionGroupId,
teeth: [],
@@ -104,6 +119,8 @@ export async function generateLabCaseTasks(
taskRows.push({
labCaseId,
treatmentDetailId: group.treatmentDetailId,
lineId: group.lineId,
sourceKey: group.sourceKey,
teeth,
treatmentType: group.treatmentType,
prosthesisTypeCode: group.prosthesisTypeCode,