TreatmentType and ProsthesisType database shcema and data updated. Lab dispatch wired through new data.

This commit is contained in:
2026-07-06 20:40:19 +03:30
parent 3e81c110a3
commit 667b08ed0c
48 changed files with 1813 additions and 275 deletions

View File

@@ -14,6 +14,8 @@ import { TreatmentsModule } from './modules/treatments/treatments.module';
import { CasesModule } from './modules/cases/cases.module';
import { TasksModule } from './modules/tasks/tasks.module';
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
@Module({
imports: [
@@ -22,7 +24,9 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
load: [configurations],
}),
PrismaModule, // ✅ ADD THIS
CatalogModule,
TreatmentCatalogModule,
ProsthesisCatalogModule,
AuthModule,
PatientsModule,
AppointmentsModule,

View File

@@ -46,7 +46,7 @@ export class CasesController {
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
getOne(@Param('id') id: string, @Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.getOne(id, organizationId, req.user.id);
return this.casesService.getOne(id, organizationId, req.user.id, req.user.language);
}
@Patch(':id/tasks/:taskId')
@@ -58,6 +58,6 @@ export class CasesController {
@Req() req,
) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id);
return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id, req.user.language);
}
}

View File

@@ -4,9 +4,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabTaskStatus, Prisma } from '@prisma/client';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
@@ -52,6 +56,7 @@ export class CasesService {
constructor(
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
private readonly catalogLabels: CatalogLabelService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -142,8 +147,8 @@ export class CasesService {
}
}
const treatmentTypes = this.treatmentCatalog
.list()
const catalog = await this.treatmentCatalog.list();
const treatmentTypes = catalog
.filter((entry) => entry.labDependent && typeCodes.has(entry.code))
.map((entry) => ({ code: entry.code, labDependent: entry.labDependent }));
@@ -218,6 +223,7 @@ export class CasesService {
labCaseId: string,
clinicOrganizationId: string,
labOrganizationId: string,
localeInput?: string | null,
) {
const labCase = await this.prisma.labCase.findFirst({
where: {
@@ -233,10 +239,18 @@ export class CasesService {
throw new NotFoundException('Case not found');
}
return { success: true, data: this.mapLabCaseDetail(labCase) };
return {
success: true,
data: await this.mapLabCaseDetail(labCase, localeInput),
};
}
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
async getOne(
labCaseId: string,
labOrganizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const labCase = await this.prisma.labCase.findFirst({
@@ -252,7 +266,7 @@ export class CasesService {
throw new NotFoundException('Case not found');
}
return { success: true, data: this.mapLabCaseDetail(labCase) };
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
}
async updateTask(
@@ -261,6 +275,7 @@ export class CasesService {
dto: UpdateLabCaseTaskDto,
labOrganizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanEditCases(actorUserId, labOrganizationId);
@@ -299,7 +314,14 @@ export class CasesService {
},
});
return { success: true, data: this.mapTask(updated) };
const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
[updated.prosthesisTypeCode],
locale,
);
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
}
async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
@@ -431,9 +453,19 @@ export class CasesService {
};
}
private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) {
private async mapLabCaseDetail(
lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>,
localeInput?: string | null,
) {
const locale = normalizeCatalogLocale(localeInput);
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
const tasksByTooth = this.groupTasksByTooth(lc.tasks);
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.groupTasksByTooth(lc.tasks, prosthesisLabels);
return {
id: lc.id,
@@ -454,7 +486,7 @@ export class CasesService {
organizationName: s.organization.name,
sentAt: s.sentAt.toISOString(),
})),
tasks: lc.tasks.map((t) => this.mapTask(t)),
tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)),
tasksByTooth,
taskProgress: {
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
@@ -468,6 +500,7 @@ export class CasesService {
id: string;
tooth: string;
treatmentType: string;
prosthesisTypeCode: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
@@ -477,47 +510,62 @@ export class CasesService {
createdAt: Date;
assignee: { id: string; name: string; email: string } | null;
}>,
prosthesisLabels: Map<string, string>,
) {
const groups = new Map<
string,
{
tooth: string;
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
tasks: ReturnType<CasesService['mapTask']>[];
}
>();
for (const task of tasks) {
const key = `${task.tooth}:${task.treatmentType}`;
const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
tooth: task.tooth,
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
tasks: [],
};
entry.tasks.push(this.mapTask(task));
entry.tasks.push(this.mapTask(task, prosthesisLabels));
groups.set(key, entry);
}
return [...groups.values()];
}
private mapTask(task: {
id: string;
tooth: string;
treatmentType: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
priority: number;
assigneeUserId: string | null;
assignedAt: Date | null;
createdAt: Date;
assignee: { id: string; name: string; email: string } | null;
}) {
private mapTask(
task: {
id: string;
tooth: string;
treatmentType: string;
prosthesisTypeCode: string;
workflowStepCode?: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
priority: number;
assigneeUserId: string | null;
assignedAt: Date | null;
createdAt: Date;
assignee: { id: string; name: string; email: string } | null;
},
prosthesisLabels: Map<string, string>,
) {
return {
id: task.id,
tooth: task.tooth,
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,

View File

@@ -0,0 +1,154 @@
import { CatalogEntityKind } from '@prisma/client';
import {
PROSTHESIS_TYPES,
buildProsthesisStepCodes,
} from '../../../prisma/catalog-seed-data';
import { generateLabCaseTasks } from './lab-case-task.generator';
function buildMockTx(options: {
existingCount?: number;
toothProsthesisRows: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
treatmentType?: string;
}>;
prosthesisTypes: Array<{
code: string;
steps: Array<{ stepOrder: number; workflowStepCode: string }>;
}>;
stepLabels?: Record<string, string>;
}) {
const created: unknown[] = [];
const tx = {
labCaseTask: {
count: jest.fn().mockResolvedValue(options.existingCount ?? 0),
createMany: jest.fn().mockImplementation(({ data }) => {
created.push(...data);
return { count: data.length };
}),
},
labCaseToothProsthesis: {
findMany: jest.fn().mockResolvedValue(
options.toothProsthesisRows.map((row) => ({
treatmentDetailId: row.treatmentDetailId,
tooth: row.tooth,
prosthesisTypeCode: row.prosthesisTypeCode,
detail: {
id: row.treatmentDetailId,
treatmentType: row.treatmentType ?? 'prosthesis',
},
})),
),
},
prosthesisType: {
findMany: jest.fn().mockResolvedValue(
options.prosthesisTypes.map((type) => ({
code: type.code,
isActive: true,
steps: type.steps.map((step) => ({
stepOrder: step.stepOrder,
labWorkflowStep: { code: step.workflowStepCode },
})),
})),
),
},
catalogTranslation: {
findMany: jest.fn().mockImplementation(({ where }) => {
const codes = where.entityCode?.in ?? [];
return codes.map((code: string) => ({
entityCode: code,
locale: 'en',
label: options.stepLabels?.[code] ?? code,
entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
}));
}),
},
};
return { tx, created };
}
function stepsFromSeed(code: string) {
const seed = PROSTHESIS_TYPES.find((type) => type.code === code);
if (!seed) {
throw new Error(`Unknown prosthesis code: ${code}`);
}
return buildProsthesisStepCodes(seed).map((workflowStepCode, index) => ({
stepOrder: index + 1,
workflowStepCode,
}));
}
describe('generateLabCaseTasks', () => {
it('creates tasks for pfm_crown with universal and type-specific steps', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
},
],
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
stepLabels: { intraoral_scan: 'Intraoral Scan', packing: 'Packing' },
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-1', 'en');
expect(count).toBe(pfmSteps.length);
expect(created).toHaveLength(pfmSteps.length);
expect(created[0]).toMatchObject({
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
workflowStepCode: 'intraoral_scan',
stepLabel: 'Intraoral Scan',
});
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).toEqual(pfmSteps.map((step) => step.workflowStepCode));
expect(stepCodes).toContain('packing');
expect(stepCodes).toContain('shipping');
expect(stepCodes).toContain('milling_wet');
});
it('omits packing and shipping for smile_design', async () => {
const smileSteps = stepsFromSeed('smile_design');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '11',
prosthesisTypeCode: 'smile_design',
},
],
prosthesisTypes: [{ code: 'smile_design', steps: smileSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-2', 'en');
expect(count).toBe(smileSteps.length);
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).not.toContain('packing');
expect(stepCodes).not.toContain('shipping');
expect(stepCodes).toContain('printer_resin');
});
it('skips generation when tasks already exist', async () => {
const { tx } = buildMockTx({
existingCount: 3,
toothProsthesisRows: [],
prosthesisTypes: [],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-3', 'en');
expect(count).toBe(0);
expect(tx.labCaseTask.createMany).not.toHaveBeenCalled();
});
});

View File

@@ -1,84 +1,83 @@
import { LabTaskStatus, Prisma } from '@prisma/client';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
type TransactionClient = Prisma.TransactionClient;
export async function generateLabCaseTasks(
tx: TransactionClient,
labCaseId: string,
localeInput?: string | null,
): Promise<number> {
const existingCount = await tx.labCaseTask.count({ where: { labCaseId } });
if (existingCount > 0) {
return 0;
}
const labCase = await tx.labCase.findUnique({
where: { id: labCaseId },
const locale = normalizeCatalogLocale(localeInput);
const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({
where: { labCaseId },
include: {
details: {
include: {
detail: {
select: { id: true, treatmentType: true, teeth: true },
},
},
detail: { select: { id: true, treatmentType: true } },
},
});
if (toothProsthesisRows.length === 0) {
return 0;
}
const prosthesisCodes = [...new Set(toothProsthesisRows.map((r) => r.prosthesisTypeCode))];
const prosthesisTypes = await tx.prosthesisType.findMany({
where: { code: { in: prosthesisCodes }, isActive: true },
include: {
steps: {
orderBy: { stepOrder: 'asc' },
include: { labWorkflowStep: { select: { code: true } } },
},
},
});
if (!labCase?.details.length) {
return 0;
}
const stepsByProsthesisCode = new Map(
prosthesisTypes.map((type) => [
type.code,
type.steps.map((s) => ({
stepOrder: s.stepOrder,
workflowStepCode: s.labWorkflowStep.code,
})),
]),
);
const treatmentTypeCodes = [...new Set(labCase.details.map((d) => d.detail.treatmentType))];
const allStepCodes = [
...new Set(
prosthesisTypes.flatMap((type) =>
type.steps.map((s) => s.labWorkflowStep.code),
),
),
];
const labDependentTypes = await tx.treatmentType.findMany({
where: { code: { in: treatmentTypeCodes }, labDependent: true },
select: { id: true, code: true },
});
if (labDependentTypes.length === 0) {
return 0;
}
const labDependentCodes = new Set(labDependentTypes.map((t) => t.code));
const workflowSteps = await tx.treatmentWorkflowStep.findMany({
where: { treatmentTypeId: { in: labDependentTypes.map((t) => t.id) } },
orderBy: [{ treatmentTypeId: 'asc' }, { stepOrder: 'asc' }],
include: { treatmentType: { select: { code: true } } },
});
const stepsByTypeCode = new Map<string, { stepOrder: number; label: string }[]>();
for (const step of workflowSteps) {
const code = step.treatmentType.code;
const list = stepsByTypeCode.get(code) ?? [];
list.push({ stepOrder: step.stepOrder, label: step.label });
stepsByTypeCode.set(code, list);
}
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
for (const link of labCase.details) {
const detail = link.detail;
if (!labDependentCodes.has(detail.treatmentType)) {
for (const row of toothProsthesisRows) {
const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? [];
if (typeSteps.length === 0) {
continue;
}
const teeth = normalizeTeeth(detail.teeth);
const typeSteps = stepsByTypeCode.get(detail.treatmentType) ?? [];
for (const tooth of teeth) {
for (const step of typeSteps) {
taskRows.push({
labCaseId,
treatmentDetailId: detail.id,
tooth,
treatmentType: detail.treatmentType,
stepOrder: step.stepOrder,
stepLabel: step.label,
status: LabTaskStatus.PENDING,
});
}
for (const step of typeSteps) {
taskRows.push({
labCaseId,
treatmentDetailId: row.treatmentDetailId,
tooth: row.tooth,
treatmentType: row.detail.treatmentType,
prosthesisTypeCode: row.prosthesisTypeCode,
workflowStepCode: step.workflowStepCode,
stepOrder: step.stepOrder,
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
status: LabTaskStatus.PENDING,
});
}
}
@@ -89,3 +88,37 @@ export async function generateLabCaseTasks(
await tx.labCaseTask.createMany({ data: taskRows });
return taskRows.length;
}
async function resolveStepLabels(
tx: TransactionClient,
stepCodes: string[],
locale: string,
): Promise<Map<string, string>> {
if (stepCodes.length === 0) {
return new Map();
}
const rows = await tx.catalogTranslation.findMany({
where: {
entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
entityCode: { in: stepCodes },
locale: { in: [locale, 'en'] },
},
select: { entityCode: true, locale: true, label: true },
});
const byCode = new Map<string, { en?: string; locale?: string }>();
for (const row of rows) {
const entry = byCode.get(row.entityCode) ?? {};
if (row.locale === 'en') entry.en = row.label;
if (row.locale === locale) entry.locale = row.label;
byCode.set(row.entityCode, entry);
}
const result = new Map<string, string>();
for (const code of stepCodes) {
const entry = byCode.get(code);
result.set(code, entry?.locale ?? entry?.en ?? code);
}
return result;
}

View File

@@ -0,0 +1,67 @@
import { Injectable } from '@nestjs/common';
import { CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
const SUPPORTED_LOCALES = ['en', 'fa', 'nl'] as const;
export type CatalogLocale = (typeof SUPPORTED_LOCALES)[number];
export function normalizeCatalogLocale(language?: string | null): CatalogLocale {
if (language === 'fa' || language === 'nl') return language;
return 'en';
}
@Injectable()
export class CatalogLabelService {
constructor(private readonly prisma: PrismaService) {}
async resolveLabels(
entityKind: CatalogEntityKind,
codes: string[],
locale: CatalogLocale,
): Promise<Map<string, string>> {
const uniqueCodes = [...new Set(codes.filter(Boolean))];
if (uniqueCodes.length === 0) {
return new Map();
}
const rows = await this.prisma.catalogTranslation.findMany({
where: {
entityKind,
entityCode: { in: uniqueCodes },
locale: { in: [locale, 'en'] },
},
select: { entityCode: true, locale: true, label: true },
});
const byCode = new Map<string, { en?: string; locale?: string }>();
for (const row of rows) {
const entry = byCode.get(row.entityCode) ?? {};
if (row.locale === 'en') entry.en = row.label;
if (row.locale === locale) entry.locale = row.label;
byCode.set(row.entityCode, entry);
}
const result = new Map<string, string>();
for (const code of uniqueCodes) {
const entry = byCode.get(code);
result.set(code, entry?.locale ?? entry?.en ?? formatCodeAsLabel(code));
}
return result;
}
async resolveLabel(
entityKind: CatalogEntityKind,
code: string,
locale: CatalogLocale,
): Promise<string> {
const map = await this.resolveLabels(entityKind, [code], locale);
return map.get(code) ?? formatCodeAsLabel(code);
}
}
export function formatCodeAsLabel(code: string): string {
return code
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}

View File

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { CatalogLabelService } from './catalog-label.service';
@Global()
@Module({
providers: [CatalogLabelService, PrismaService],
exports: [CatalogLabelService],
})
export class CatalogModule {}

View File

@@ -143,7 +143,7 @@ export class OrganizationController {
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get one case exchanged with a connected organization' })
getConnectionCase(
@Req() req: { user: { id: string; organizationId?: string } },
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
@Param('connectionId') connectionId: string,
@Param('caseId') caseId: string,
) {
@@ -153,6 +153,7 @@ export class OrganizationController {
organizationId,
connectionId,
caseId,
req.user.language,
);
}

View File

@@ -379,6 +379,7 @@ export class OrganizationService {
organizationId: string,
connectionId: string,
caseId: string,
localeInput?: string | null,
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
@@ -392,6 +393,7 @@ export class OrganizationService {
caseId,
clinicOrganizationId,
labOrganizationId,
localeInput,
);
return {

View File

@@ -0,0 +1,26 @@
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProsthesisCatalogService } from './prosthesis-catalog.service';
@ApiTags('prosthesis-catalog')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('prosthesis-catalog')
export class ProsthesisCatalogController {
constructor(private readonly prosthesisCatalogService: ProsthesisCatalogService) {}
@Get()
@ApiOperation({
summary: 'List prosthesis types (optionally scoped to lab — v1 returns all types)',
})
list(
@Req() req: { user?: { language?: string | null } },
@Query('labOrganizationId') _labOrganizationId?: string,
) {
return this.prosthesisCatalogService.list(req.user?.language).then((data) => ({
success: true,
data,
}));
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { ProsthesisCatalogController } from './prosthesis-catalog.controller';
import { ProsthesisCatalogService } from './prosthesis-catalog.service';
@Module({
controllers: [ProsthesisCatalogController],
providers: [ProsthesisCatalogService, PrismaService],
exports: [ProsthesisCatalogService],
})
export class ProsthesisCatalogModule {}

View File

@@ -0,0 +1,98 @@
import { Injectable, BadRequestException, OnModuleInit } from '@nestjs/common';
import { CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
CatalogLabelService,
CatalogLocale,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
export type ProsthesisTypeCatalogEntry = {
code: string;
sortOrder: number;
label: string;
};
@Injectable()
export class ProsthesisCatalogService implements OnModuleInit {
private loaded = false;
private byCode = new Map<string, { sortOrder: number }>();
constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
) {}
async onModuleInit() {
await this.refresh();
}
async refresh(): Promise<void> {
const rows = await this.prisma.prosthesisType.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
select: { code: true, sortOrder: true },
});
this.byCode = new Map(rows.map((row) => [row.code, { sortOrder: row.sortOrder }]));
this.loaded = true;
}
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> {
this.ensureLoaded();
const locale = normalizeCatalogLocale(localeInput);
const codes = [...this.byCode.keys()];
const labels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
codes,
locale,
);
return codes
.map((code) => ({
code,
sortOrder: this.byCode.get(code)!.sortOrder,
label: labels.get(code) ?? code,
}))
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
}
assertKnownProsthesisType(code: string): void {
this.ensureLoaded();
if (!this.byCode.has(code)) {
throw new BadRequestException(`Unknown prosthesis type: ${code}`);
}
}
async getStepCodesForProsthesisType(prosthesisTypeCode: string): Promise<string[]> {
const type = await this.prisma.prosthesisType.findUnique({
where: { code: prosthesisTypeCode },
select: {
steps: {
orderBy: { stepOrder: 'asc' },
select: { labWorkflowStep: { select: { code: true } } },
},
},
});
if (!type) {
return [];
}
return type.steps.map((s) => s.labWorkflowStep.code);
}
async resolveStepLabels(stepCodes: string[], locale: CatalogLocale): Promise<Map<string, string>> {
return this.catalogLabels.resolveLabels(
CatalogEntityKind.LAB_WORKFLOW_STEP,
stepCodes,
locale,
);
}
private ensureLoaded() {
if (!this.loaded) {
throw new Error('Prosthesis catalog is not loaded yet');
}
}
}

View File

@@ -16,7 +16,7 @@ export class TasksController {
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
list(@Query() query: ListLabTasksDto, @Req() req) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.list(organizationId, req.user.id, query);
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
}
@Patch(':taskId')
@@ -27,6 +27,12 @@ export class TasksController {
@Req() req,
) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.updateStatus(taskId, dto, organizationId, req.user.id);
return this.tasksService.updateStatus(
taskId,
dto,
organizationId,
req.user.id,
req.user.language,
);
}
}

View File

@@ -4,8 +4,12 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabTaskStatus, Prisma } from '@prisma/client';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
const taskListInclude = {
@@ -24,7 +28,10 @@ const taskListInclude = {
@Injectable()
export class TasksService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
@@ -33,7 +40,12 @@ export class TasksService {
return user.organizationId;
}
async list(labOrganizationId: string, actorUserId: string, query: ListLabTasksDto) {
async list(
labOrganizationId: string,
actorUserId: string,
query: ListLabTasksDto,
localeInput?: string | null,
) {
await this.assertCanReadTasks(actorUserId, labOrganizationId);
const membership = await this.getMembership(actorUserId, labOrganizationId);
@@ -71,10 +83,18 @@ export class TasksService {
this.prisma.labCaseTask.count({ where }),
]);
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
locale,
);
return {
success: true,
data: {
items: items.map((task) => this.mapTaskListItem(task)),
items: items.map((task) => this.mapTaskListItem(task, prosthesisLabels)),
pagination: {
page,
limit,
@@ -90,6 +110,7 @@ export class TasksService {
dto: UpdateLabTaskDto,
labOrganizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanEditTasks(actorUserId, labOrganizationId);
@@ -123,17 +144,28 @@ export class TasksService {
include: taskListInclude,
});
return { success: true, data: this.mapTaskListItem(updated) };
const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
[updated.prosthesisTypeCode],
locale,
);
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
}
private mapTaskListItem(
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
prosthesisLabels: Map<string, string>,
) {
return {
id: task.id,
labCaseId: task.labCaseId,
tooth: task.tooth,
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,

View File

@@ -1,4 +1,4 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TreatmentCatalogService } from './treatment-catalog.service';
@@ -11,11 +11,9 @@ export class TreatmentCatalogController {
constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {}
@Get()
@ApiOperation({ summary: 'List treatment types from the catalog (data-driven)' })
list() {
return {
success: true,
data: this.treatmentCatalogService.list(),
};
@ApiOperation({ summary: 'List active treatment types with localized labels' })
async list(@Req() req: { user?: { language?: string | null } }) {
const data = await this.treatmentCatalogService.list(req.user?.language);
return { success: true, data };
}
}

View File

@@ -1,11 +1,18 @@
import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import { CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
export type TreatmentTypeCatalogEntry = {
id: string;
code: string;
labDependent: boolean;
sortOrder: number;
label: string;
};
@Injectable()
@@ -13,7 +20,10 @@ export class TreatmentCatalogService implements OnModuleInit {
private loaded = false;
private byCode = new Map<string, TreatmentTypeCatalogEntry>();
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
) {}
async onModuleInit() {
await this.refresh();
@@ -21,17 +31,46 @@ export class TreatmentCatalogService implements OnModuleInit {
async refresh(): Promise<void> {
const rows = await this.prisma.treatmentType.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
select: { id: true, code: true, labDependent: true, sortOrder: true },
});
this.byCode = new Map(rows.map((row) => [row.code, row]));
this.byCode = new Map(
rows.map((row) => [
row.code,
{
id: row.id,
code: row.code,
labDependent: row.labDependent,
sortOrder: row.sortOrder,
label: row.code,
},
]),
);
this.loaded = true;
}
list(): TreatmentTypeCatalogEntry[] {
async list(localeInput?: string | null): Promise<TreatmentTypeCatalogEntry[]> {
await this.ensureLabels(localeInput);
return [...this.byCode.values()].sort(
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
);
}
private async ensureLabels(localeInput?: string | null) {
this.ensureLoaded();
return [...this.byCode.values()];
const locale = normalizeCatalogLocale(localeInput);
const codes = [...this.byCode.keys()];
const labels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.TREATMENT_TYPE,
codes,
locale,
);
for (const [code, entry] of this.byCode) {
entry.label = labels.get(code) ?? entry.code;
}
}
getByCode(code: string): TreatmentTypeCatalogEntry | undefined {

View File

@@ -45,6 +45,19 @@ export class SaveTreatmentDraftDto {
details: SaveTreatmentDetailDto[];
}
export class LabCaseToothProsthesisDto {
@IsUUID()
treatmentDetailId: string;
@IsString()
@MaxLength(8)
tooth: string;
@IsString()
@MaxLength(64)
prosthesisTypeCode: string;
}
export class SaveLabCaseDto {
@IsString()
@MaxLength(64)
@@ -67,6 +80,12 @@ export class SaveLabCaseDto {
@ArrayMinSize(1)
@IsUUID(undefined, { each: true })
treatmentDetailIds: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => LabCaseToothProsthesisDto)
toothProsthesis?: LabCaseToothProsthesisDto[];
}
export class SaveTreatmentLabCasesDto {

View File

@@ -0,0 +1,52 @@
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
describe('assertCompleteToothProsthesisMap', () => {
const prosthesisDetailId = 'detail-1';
it('passes when every prosthesis tooth has a mapping', () => {
expect(() =>
assertCompleteToothProsthesisMap({
details: [
{
treatmentDetailId: prosthesisDetailId,
detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: ['14', '15'] },
},
],
toothProsthesis: [
{ treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' },
{ treatmentDetailId: prosthesisDetailId, tooth: '15', prosthesisTypeCode: 'pfm_crown' },
],
}),
).not.toThrow();
});
it('ignores non-prosthesis details', () => {
expect(() =>
assertCompleteToothProsthesisMap({
details: [
{
treatmentDetailId: 'endo-1',
detail: { id: 'endo-1', treatmentType: 'endo', teeth: ['36'] },
},
],
toothProsthesis: [],
}),
).not.toThrow();
});
it('throws when a prosthesis tooth is missing from the map', () => {
expect(() =>
assertCompleteToothProsthesisMap({
details: [
{
treatmentDetailId: prosthesisDetailId,
detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: ['14', '15'] },
},
],
toothProsthesis: [
{ treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' },
],
}),
).toThrow('missing tooth 15');
});
});

View File

@@ -0,0 +1,37 @@
import { BadRequestException } from '@nestjs/common';
import { normalizeTeeth } from './treatment.utils';
export type LabCaseProsthesisLink = {
treatmentDetailId: string;
detail: { id: string; treatmentType: string; teeth: unknown };
};
export type LabCaseToothProsthesisRow = {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
};
export function assertCompleteToothProsthesisMap(labCase: {
details: LabCaseProsthesisLink[];
toothProsthesis: LabCaseToothProsthesisRow[];
}) {
const prosthesisByKey = new Set(
labCase.toothProsthesis.map((tp) => `${tp.treatmentDetailId}:${tp.tooth}`),
);
for (const link of labCase.details) {
if (link.detail.treatmentType !== 'prosthesis') {
continue;
}
const teeth = normalizeTeeth(link.detail.teeth);
for (const tooth of teeth) {
const key = `${link.treatmentDetailId}:${tooth}`;
if (!prosthesisByKey.has(key)) {
throw new BadRequestException(
`Each tooth must have a prosthesis type before sending (missing tooth ${tooth})`,
);
}
}
}
}

View File

@@ -184,9 +184,14 @@ export class TreatmentsController {
@ApiOperation({ summary: 'Send a lab case to its destination organization (TAB_TREATMENT_EDIT)' })
sendLabCase(
@Param('labCaseId') labCaseId: string,
@Req() req: { user: { id: string; organizationId?: string } },
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.sendLabCase(labCaseId, organizationId, req.user.id);
return this.treatmentsService.sendLabCase(
labCaseId,
organizationId,
req.user.id,
req.user.language,
);
}
}

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
import { TreatmentsController } from './treatments.controller';
import { TreatmentsService } from './treatments.service';
@Module({
imports: [ProsthesisCatalogModule],
controllers: [TreatmentsController],
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
})

View File

@@ -10,6 +10,7 @@ import { join } from 'path';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import {
SaveTreatmentDraftDto,
@@ -19,6 +20,7 @@ import {
generateTreatmentTitle,
normalizeTeeth,
} from './treatment.utils';
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
const treatmentInclude = {
details: {
@@ -53,6 +55,7 @@ const treatmentInclude = {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
toothProsthesis: true,
},
},
};
@@ -64,6 +67,7 @@ export class TreatmentsService {
constructor(
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
private readonly prosthesisCatalog: ProsthesisCatalogService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -326,7 +330,7 @@ export class TreatmentsService {
const details = await this.prisma.treatmentDetail.findMany({
where: { treatmentId: treatment.id, id: { in: detailIds } },
select: { id: true, treatmentType: true },
select: { id: true, treatmentType: true, teeth: true },
});
if (details.length !== uniqueDetailIds.size) {
throw new BadRequestException('One or more treatment details were not found');
@@ -336,12 +340,30 @@ export class TreatmentsService {
this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType);
}
const detailById = new Map(details.map((d) => [d.id, d]));
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
for (const lc of dto.labCases) {
if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) {
throw new BadRequestException('Destination organization is not an active linked counterpart');
}
for (const row of lc.toothProsthesis ?? []) {
if (!lc.treatmentDetailIds.includes(row.treatmentDetailId)) {
throw new BadRequestException(
'Tooth prosthesis must reference a detail included in this lab case',
);
}
const detail = detailById.get(row.treatmentDetailId);
if (!detail) {
throw new BadRequestException('Tooth prosthesis references an unknown treatment detail');
}
const teeth = normalizeTeeth(detail.teeth);
if (!teeth.includes(row.tooth)) {
throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`);
}
this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);
}
}
const saved = await this.prisma.$transaction(async (tx) => {
@@ -395,6 +417,18 @@ export class TreatmentsService {
treatmentDetailId,
})),
});
await tx.labCaseToothProsthesis.deleteMany({ where: { labCaseId: row.id } });
if (lc.toothProsthesis?.length) {
await tx.labCaseToothProsthesis.createMany({
data: lc.toothProsthesis.map((tp) => ({
labCaseId: row.id,
treatmentDetailId: tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
});
}
}
return tx.treatment.findUniqueOrThrow({
@@ -410,6 +444,7 @@ export class TreatmentsService {
labCaseId: string,
organizationId: string,
actorUserId: string,
actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
@@ -421,7 +456,12 @@ export class TreatmentsService {
include: {
treatment: { select: { providerUserId: true } },
sends: { select: { organizationId: true } },
details: { select: { treatmentDetailId: true } },
details: {
include: {
detail: { select: { id: true, treatmentType: true, teeth: true } },
},
},
toothProsthesis: true,
},
});
@@ -437,6 +477,8 @@ export class TreatmentsService {
throw new BadRequestException('Lab case must include at least one treatment detail');
}
assertCompleteToothProsthesisMap(labCase);
if (labCase.treatment.providerUserId !== actorUserId) {
const membership = await this.getMembership(actorUserId, organizationId);
if (!membership?.isOwner) {
@@ -473,7 +515,7 @@ export class TreatmentsService {
});
}
await generateLabCaseTasks(tx, labCaseId);
await generateLabCaseTasks(tx, labCaseId, actorLanguage);
});
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
@@ -490,6 +532,7 @@ export class TreatmentsService {
orderBy: [{ sentAt: 'asc' }],
include: { organization: { select: { id: true, name: true } } },
},
toothProsthesis: true,
},
});
@@ -722,6 +765,11 @@ export class TreatmentsService {
sentAt: Date;
organization?: { id: string; name: string };
}>;
toothProsthesis?: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
}>;
}) {
return {
id: lc.id,
@@ -736,6 +784,11 @@ export class TreatmentsService {
treatmentType: d.detail?.treatmentType ?? '',
teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [],
})),
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
treatmentDetailId: tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
sends:
lc.sends?.map((s) => ({
organizationId: s.organizationId,