improvement: tasks and cases feature updated based on the new prosthesis types and their steps. the whole assignment proccess removed from the flow.

This commit is contained in:
2026-07-07 15:31:09 +03:30
parent ed7e7b1d8f
commit cb63ced4e3
35 changed files with 1819 additions and 454 deletions

View File

@@ -22,7 +22,8 @@
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed",
"prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts"
"prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts",
"prisma:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"

View File

@@ -0,0 +1,83 @@
-- Lab workflow refactor: remove task assignment/priority, group tasks by prosthesis,
-- add importance flag, status timeline, and per-case comments.
-- Local dev data only: existing tasks are truncated and regenerated on next dispatch/send.
-- 1. Clear existing task data (task shape changes: tooth -> teeth[]).
TRUNCATE TABLE "lab_case_tasks" CASCADE;
-- 2. Drop assignment / priority machinery.
ALTER TABLE "lab_case_tasks" DROP CONSTRAINT IF EXISTS "lab_case_tasks_assigneeUserId_fkey";
DROP INDEX IF EXISTS "lab_case_tasks_assigneeUserId_priority_createdAt_idx";
DROP INDEX IF EXISTS "lab_case_tasks_assignedAt_labCaseId_priority_idx";
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_tooth_stepOrder_key";
ALTER TABLE "lab_case_tasks"
DROP COLUMN IF EXISTS "assigneeUserId",
DROP COLUMN IF EXISTS "assignedAt",
DROP COLUMN IF EXISTS "priority",
DROP COLUMN IF EXISTS "tooth";
-- 3. Rebuild LabTaskStatus enum without PENDING.
ALTER TABLE "lab_case_tasks" ALTER COLUMN "status" DROP DEFAULT;
ALTER TYPE "LabTaskStatus" RENAME TO "LabTaskStatus_old";
CREATE TYPE "LabTaskStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED');
ALTER TABLE "lab_case_tasks"
ALTER COLUMN "status" TYPE "LabTaskStatus" USING ("status"::text::"LabTaskStatus");
ALTER TABLE "lab_case_tasks" ALTER COLUMN "status" SET DEFAULT 'IN_PROGRESS';
DROP TYPE "LabTaskStatus_old";
-- 4. New task columns.
ALTER TABLE "lab_case_tasks" ADD COLUMN "teeth" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "lab_case_tasks" ALTER COLUMN "teeth" DROP DEFAULT;
ALTER TABLE "lab_case_tasks" ADD COLUMN "isImportant" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "lab_case_tasks" ADD COLUMN "lastStatusChangedByUserId" TEXT;
ALTER TABLE "lab_case_tasks" ADD COLUMN "lastStatusChangedAt" TIMESTAMP(3);
-- 5. New unique + indexes.
CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key"
ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "prosthesisTypeCode", "stepOrder");
CREATE INDEX "lab_case_tasks_labCaseId_isImportant_idx"
ON "lab_case_tasks"("labCaseId", "isImportant");
ALTER TABLE "lab_case_tasks" ADD CONSTRAINT "lab_case_tasks_lastStatusChangedByUserId_fkey"
FOREIGN KEY ("lastStatusChangedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- 6. Task status timeline.
CREATE TABLE "lab_case_task_status_events" (
"id" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"fromStatus" "LabTaskStatus",
"toStatus" "LabTaskStatus" NOT NULL,
"changedByUserId" TEXT,
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lab_case_task_status_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "lab_case_task_status_events_taskId_changedAt_idx"
ON "lab_case_task_status_events"("taskId", "changedAt");
ALTER TABLE "lab_case_task_status_events" ADD CONSTRAINT "lab_case_task_status_events_taskId_fkey"
FOREIGN KEY ("taskId") REFERENCES "lab_case_tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "lab_case_task_status_events" ADD CONSTRAINT "lab_case_task_status_events_changedByUserId_fkey"
FOREIGN KEY ("changedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- 7. Per-case comments.
CREATE TYPE "LabCaseCommentSide" AS ENUM ('LAB', 'CLINIC');
CREATE TABLE "lab_case_comments" (
"id" TEXT NOT NULL,
"labCaseId" TEXT NOT NULL,
"authorUserId" TEXT,
"authorOrganizationId" TEXT,
"authorSide" "LabCaseCommentSide" NOT NULL,
"body" TEXT NOT NULL,
"visibleToClinic" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "lab_case_comments_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "lab_case_comments_labCaseId_createdAt_idx"
ON "lab_case_comments"("labCaseId", "createdAt");
ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_labCaseId_fkey"
FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_authorUserId_fkey"
FOREIGN KEY ("authorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_authorOrganizationId_fkey"
FOREIGN KEY ("authorOrganizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,60 @@
/**
* Dev-only: regenerate lab case tasks from existing LabCaseToothProsthesis rows.
* Usage: npx ts-node prisma/regenerate-lab-tasks.ts
*
* The lab workflow refactor truncated lab_case_tasks. This rebuilds task sets
* (grouped by treatment detail + prosthesis type, one set per workflow step)
* for every already-sent case that still has prosthesis selections.
*/
import { PrismaClient } from '@prisma/client';
import { config } from 'dotenv';
import path from 'path';
import { generateLabCaseTasks } from '../src/modules/cases/lab-case-task.generator';
const envPath = path.join(__dirname, '..', '.env');
config({ path: envPath });
if (process.env.NODE_ENV === 'production') {
console.error('regenerate-lab-tasks is not allowed in production');
process.exit(1);
}
const prisma = new PrismaClient();
async function main() {
const cases = await prisma.labCase.findMany({
where: {
sentAt: { not: null },
toothProsthesis: { some: {} },
},
select: {
id: true,
treatment: { select: { organization: { select: { owner: { select: { language: true } } } } } },
},
});
console.log(`Regenerating tasks for ${cases.length} sent case(s)...`);
let total = 0;
for (const labCase of cases) {
const locale = labCase.treatment.organization.owner.language ?? 'en';
// Clear any stale tasks first so the generator's "already exists" guard passes.
await prisma.labCaseTask.deleteMany({ where: { labCaseId: labCase.id } });
const created = await prisma.$transaction((tx) =>
generateLabCaseTasks(tx, labCase.id, locale),
);
total += created;
console.log(` - ${labCase.id}: ${created} task(s)`);
}
console.log(`Done. ${total} task(s) created.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -21,6 +21,8 @@ const prisma = new PrismaClient();
// FK-safe order: children before parents.
const TABLES_IN_ORDER = [
'lab_case_task_status_events',
'lab_case_comments',
'lab_case_tasks',
'lab_case_sends',
'lab_case_tooth_prosthesis',

View File

@@ -23,7 +23,9 @@ model User {
sessions Session[] // 👈 ADD THIS - opposite relation for Session
sentStaffInvites StaffInvitation[]
sentOrganizationInvitations OrganizationInvitation[]
assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee")
statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy")
labCaseTaskStatusEvents LabCaseTaskStatusEvent[]
labCaseComments LabCaseComment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -65,6 +67,7 @@ model Organization {
appointments Appointment[]
treatments Treatment[]
labCaseSends LabCaseSend[]
labCaseComments LabCaseComment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -115,11 +118,15 @@ model Appointment {
}
enum LabTaskStatus {
PENDING
IN_PROGRESS
COMPLETED
}
enum LabCaseCommentSide {
LAB
CLINIC
}
model Treatment {
id String @id @default(uuid())
organizationId String
@@ -194,6 +201,7 @@ model LabCase {
sends LabCaseSend[]
tasks LabCaseTask[]
toothProsthesis LabCaseToothProsthesis[]
comments LabCaseComment[]
@@index([treatmentId, sortOrder])
@@map("lab_cases")
@@ -306,31 +314,66 @@ model LabCaseTask {
id String @id @default(uuid())
labCaseId String
treatmentDetailId String
tooth String
teeth Json
treatmentType String
prosthesisTypeCode String
workflowStepCode String
stepOrder Int
stepLabel String
assigneeUserId String?
assignedAt DateTime?
priority Int @default(3)
status LabTaskStatus @default(PENDING)
isImportant Boolean @default(false)
status LabTaskStatus @default(IN_PROGRESS)
lastStatusChangedByUserId String?
lastStatusChangedAt DateTime?
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull)
lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull)
statusEvents LabCaseTaskStatusEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([labCaseId, treatmentDetailId, tooth, stepOrder])
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
@@index([labCaseId, status])
@@index([assigneeUserId, priority, createdAt])
@@index([assignedAt, labCaseId, priority])
@@index([labCaseId, isImportant])
@@map("lab_case_tasks")
}
model LabCaseTaskStatusEvent {
id String @id @default(uuid())
taskId String
fromStatus LabTaskStatus?
toStatus LabTaskStatus
changedByUserId String?
changedAt DateTime @default(now())
task LabCaseTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
changedBy User? @relation(fields: [changedByUserId], references: [id], onDelete: SetNull)
@@index([taskId, changedAt])
@@map("lab_case_task_status_events")
}
model LabCaseComment {
id String @id @default(uuid())
labCaseId String
authorUserId String?
authorOrganizationId String?
authorSide LabCaseCommentSide
body String
visibleToClinic Boolean @default(false)
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
authorUser User? @relation(fields: [authorUserId], references: [id], onDelete: SetNull)
authorOrganization Organization? @relation(fields: [authorOrganizationId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([labCaseId, createdAt])
@@map("lab_case_comments")
}
model Plan {
id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"

View File

@@ -16,6 +16,7 @@ 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';
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
@Module({
imports: [
@@ -33,6 +34,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis
TreatmentsModule,
CasesModule,
TasksModule,
LabCaseCommentsModule,
StaffModule,
OrganizationModule,
AdminModule.forRoot(),

View File

@@ -35,13 +35,6 @@ export class CasesController {
return this.casesService.listFilterOptions(organizationId, req.user.id);
}
@Get('assignable-members')
@ApiOperation({ summary: 'List lab staff who can be assigned to tasks' })
listAssignableMembers(@Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.listAssignableMembers(organizationId, req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
getOne(@Param('id') id: string, @Req() req) {
@@ -50,7 +43,7 @@ export class CasesController {
}
@Patch(':id/tasks/:taskId')
@ApiOperation({ summary: 'Update task assignee or priority' })
@ApiOperation({ summary: 'Toggle task important flag' })
updateTask(
@Param('id') id: string,
@Param('taskId') taskId: string,

View File

@@ -14,6 +14,7 @@ import {
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
import { normalizeTaskTeeth } from './lab-case-task.util';
const labCaseListInclude = {
treatment: {
@@ -41,16 +42,29 @@ const labCaseListInclude = {
},
tasks: {
orderBy: [
{ tooth: 'asc' as const },
{ treatmentType: 'asc' as const },
{ treatmentDetailId: 'asc' as const },
{ prosthesisTypeCode: 'asc' as const },
{ stepOrder: 'asc' as const },
],
include: {
assignee: { select: { id: true, name: true, email: true } },
lastStatusChangedBy: { select: { id: true, name: true } },
statusEvents: {
orderBy: { changedAt: 'asc' as const },
include: { changedBy: { select: { id: true, name: true } } },
},
},
},
} satisfies Prisma.LabCaseInclude;
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
include: {
lastStatusChangedBy: { select: { id: true; name: true } };
statusEvents: {
include: { changedBy: { select: { id: true; name: true } } };
};
};
}>;
@Injectable()
export class CasesService {
constructor(
@@ -294,23 +308,15 @@ export class CasesService {
throw new NotFoundException('Task not found');
}
if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) {
await this.ensureAssignableMember(dto.assigneeUserId, labOrganizationId);
}
const updated = await this.prisma.labCaseTask.update({
where: { id: taskId },
data: {
...(dto.assigneeUserId !== undefined
? {
assigneeUserId: dto.assigneeUserId,
assignedAt: dto.assigneeUserId === null ? null : new Date(),
}
: {}),
...(dto.priority !== undefined ? { priority: dto.priority } : {}),
},
data: { isImportant: dto.isImportant },
include: {
assignee: { select: { id: true, name: true, email: true } },
lastStatusChangedBy: { select: { id: true, name: true } },
statusEvents: {
orderBy: { changedAt: 'asc' },
include: { changedBy: { select: { id: true, name: true } } },
},
},
});
@@ -324,35 +330,6 @@ export class CasesService {
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
}
async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const memberships = await this.prisma.membership.findMany({
where: { organizationId: labOrganizationId, isActive: true },
include: {
user: { select: { id: true, name: true, email: true } },
permissions: { include: { permission: true } },
},
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
});
return {
success: true,
data: memberships
.filter((m) => {
if (m.isOwner) return true;
const names = m.permissions.map((p) => p.permission.name);
return names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT');
})
.map((m) => ({
userId: m.user.id,
name: m.user.name,
email: m.user.email,
isOwner: m.isOwner,
})),
};
}
private buildListWhere(
labOrganizationId: string,
query: ListLabCasesDto,
@@ -465,7 +442,7 @@ export class CasesService {
prosthesisCodes,
locale,
);
const tasksByTooth = this.groupTasksByTooth(lc.tasks, prosthesisLabels);
const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
return {
id: lc.id,
@@ -495,27 +472,15 @@ export class CasesService {
};
}
private groupTasksByTooth(
tasks: Array<{
id: string;
tooth: string;
treatmentType: string;
prosthesisTypeCode: 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 groupTasks(
tasks: LabCaseTaskWithRelations[],
prosthesisLabels: Map<string, string>,
) {
const groups = new Map<
string,
{
tooth: string;
treatmentDetailId: string;
teeth: string[];
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
@@ -524,9 +489,10 @@ export class CasesService {
>();
for (const task of tasks) {
const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`;
const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
tooth: task.tooth,
treatmentDetailId: task.treatmentDetailId,
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
@@ -541,26 +507,13 @@ export class CasesService {
}
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;
},
task: LabCaseTaskWithRelations,
prosthesisLabels: Map<string, string>,
) {
return {
id: task.id,
tooth: task.tooth,
treatmentDetailId: task.treatmentDetailId,
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
@@ -569,32 +522,24 @@ export class CasesService {
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
priority: task.priority,
assignedAt: task.assignedAt?.toISOString() ?? null,
isImportant: task.isImportant,
createdAt: task.createdAt.toISOString(),
assigneeUserId: task.assigneeUserId,
assignee: task.assignee
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
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 async ensureAssignableMember(userId: string, labOrganizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId: labOrganizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
});
if (!membership) {
throw new BadRequestException('Assignee must be an active member of this lab');
}
if (membership.isOwner) return;
const names = membership.permissions.map((p) => p.permission.name);
if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) {
return;
}
throw new BadRequestException('Assignee must have access to the Tasks tab');
}
private async assertCanReadCases(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {

View File

@@ -1,18 +1,9 @@
import { Transform } from 'class-transformer';
import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class UpdateLabCaseTaskDto {
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsUUID()
assigneeUserId?: string | null;
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(5)
priority?: number;
@IsBoolean()
isImportant: boolean;
}
export class ListLabCasesDto {

View File

@@ -101,10 +101,11 @@ describe('generateLabCaseTasks', () => {
expect(count).toBe(pfmSteps.length);
expect(created).toHaveLength(pfmSteps.length);
expect(created[0]).toMatchObject({
tooth: '14',
teeth: ['14'],
prosthesisTypeCode: 'pfm_crown',
workflowStepCode: 'intraoral_scan',
stepLabel: 'Intraoral Scan',
status: 'IN_PROGRESS',
});
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
@@ -115,6 +116,35 @@ describe('generateLabCaseTasks', () => {
expect(stepCodes).toContain('milling_wet');
});
it('groups teeth sharing a prosthesis in one detail, and keeps other prosthesis separate', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const zirconiaSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{ treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'pfm_crown' },
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' },
{ treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'monolithic_zirconia' },
],
prosthesisTypes: [
{ code: 'pfm_crown', steps: pfmSteps },
{ code: 'monolithic_zirconia', steps: zirconiaSteps },
],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-group', 'en');
expect(count).toBe(pfmSteps.length + zirconiaSteps.length);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
const pfmRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown');
const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia');
expect(pfmRows).toHaveLength(pfmSteps.length);
expect(zirconiaRows).toHaveLength(zirconiaSteps.length);
// Teeth sharing the prosthesis in the same detail are merged and sorted.
expect(pfmRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(true);
expect(zirconiaRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['16']))).toBe(true);
});
it('omits packing and shipping for smile_design', async () => {
const smileSteps = stepsFromSeed('smile_design');
const { tx, created } = buildMockTx({

View File

@@ -58,25 +58,46 @@ export async function generateLabCaseTasks(
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
// Group teeth that share the same (treatment detail + prosthesis type): one task set
// per group, with each step covering every tooth in that group.
const groups = new Map<
string,
{ treatmentDetailId: string; treatmentType: string; prosthesisTypeCode: string; teeth: string[] }
>();
for (const row of toothProsthesisRows) {
const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? [];
const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`;
const group = groups.get(key) ?? {
treatmentDetailId: row.treatmentDetailId,
treatmentType: row.detail.treatmentType,
prosthesisTypeCode: row.prosthesisTypeCode,
teeth: [],
};
group.teeth.push(row.tooth);
groups.set(key, group);
}
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
for (const group of groups.values()) {
const typeSteps = stepsByProsthesisCode.get(group.prosthesisTypeCode) ?? [];
if (typeSteps.length === 0) {
continue;
}
const teeth = sortTeeth(group.teeth);
for (const step of typeSteps) {
taskRows.push({
labCaseId,
treatmentDetailId: row.treatmentDetailId,
tooth: row.tooth,
treatmentType: row.detail.treatmentType,
prosthesisTypeCode: row.prosthesisTypeCode,
treatmentDetailId: group.treatmentDetailId,
teeth,
treatmentType: group.treatmentType,
prosthesisTypeCode: group.prosthesisTypeCode,
workflowStepCode: step.workflowStepCode,
stepOrder: step.stepOrder,
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
status: LabTaskStatus.PENDING,
status: LabTaskStatus.IN_PROGRESS,
});
}
}
@@ -89,6 +110,15 @@ export async function generateLabCaseTasks(
return taskRows.length;
}
function sortTeeth(teeth: string[]): string[] {
return [...new Set(teeth)].sort((a, b) => {
const na = Number(a);
const nb = Number(b);
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
return a.localeCompare(b);
});
}
async function resolveStepLabels(
tx: TransactionClient,
stepCodes: string[],

View File

@@ -0,0 +1,11 @@
import { Prisma } from '@prisma/client';
/** Normalize the JSON `teeth` column of a lab case task into a clean string[]. */
export function normalizeTaskTeeth(value: Prisma.JsonValue | null | undefined): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')
.map((v) => String(v));
}

View File

@@ -0,0 +1,17 @@
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateLabCaseCommentDto {
@IsString()
@MinLength(1)
@MaxLength(2000)
body: string;
@IsOptional()
@IsBoolean()
visibleToClinic?: boolean;
}
export class SetCommentVisibilityDto {
@IsBoolean()
visibleToClinic: boolean;
}

View File

@@ -0,0 +1,61 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
CreateLabCaseCommentDto,
SetCommentVisibilityDto,
} from './dto/lab-case-comment.dto';
import { LabCaseCommentsService } from './lab-case-comments.service';
@ApiTags('case-comments')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard, LabOrgGuard)
@Controller('case-comments')
export class LabCaseCommentsController {
constructor(private readonly service: LabCaseCommentsService) {}
private orgId(req: { user: { organizationId?: string } }) {
return req.user.organizationId as string;
}
@Get(':caseId')
@ApiOperation({ summary: 'List comments for a lab case (lab side)' })
list(@Param('caseId') caseId: string, @Req() req) {
return this.service.listForLab(caseId, this.orgId(req), req.user.id);
}
@Post(':caseId')
@ApiOperation({ summary: 'Add a comment to a lab case (lab side)' })
add(
@Param('caseId') caseId: string,
@Body() dto: CreateLabCaseCommentDto,
@Req() req,
) {
return this.service.addForLab(caseId, this.orgId(req), req.user.id, dto);
}
@Patch('item/:commentId/visibility')
@ApiOperation({ summary: 'Toggle whether a comment is visible to the clinic' })
setVisibility(
@Param('commentId') commentId: string,
@Body() dto: SetCommentVisibilityDto,
@Req() req,
) {
return this.service.setVisibility(
commentId,
this.orgId(req),
req.user.id,
dto.visibleToClinic,
);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { LabCaseCommentsController } from './lab-case-comments.controller';
import { LabCaseCommentsService } from './lab-case-comments.service';
@Module({
controllers: [LabCaseCommentsController],
providers: [LabCaseCommentsService, PrismaService, LabOrgGuard],
exports: [LabCaseCommentsService],
})
export class LabCaseCommentsModule {}

View File

@@ -0,0 +1,183 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabCaseCommentSide, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
const commentInclude = {
authorUser: { select: { id: true, name: true } },
authorOrganization: { select: { id: true, name: true } },
} satisfies Prisma.LabCaseCommentInclude;
type CommentWithRelations = Prisma.LabCaseCommentGetPayload<{
include: typeof commentInclude;
}>;
@Injectable()
export class LabCaseCommentsService {
constructor(private readonly prisma: PrismaService) {}
// ---------- Lab side (TAB_TASKS_EDIT) ----------
async listForLab(caseId: string, labOrganizationId: string, actorUserId: string) {
await this.assertLabCanComment(caseId, labOrganizationId, actorUserId);
const comments = await this.fetchComments(caseId);
return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) };
}
async addForLab(
caseId: string,
labOrganizationId: string,
actorUserId: string,
dto: CreateLabCaseCommentDto,
) {
await this.assertLabCanComment(caseId, labOrganizationId, actorUserId);
const created = await this.prisma.labCaseComment.create({
data: {
labCaseId: caseId,
authorUserId: actorUserId,
authorOrganizationId: labOrganizationId,
authorSide: LabCaseCommentSide.LAB,
body: dto.body.trim(),
visibleToClinic: dto.visibleToClinic ?? false,
},
include: commentInclude,
});
return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) };
}
async setVisibility(
commentId: string,
labOrganizationId: string,
actorUserId: string,
visibleToClinic: boolean,
) {
const comment = await this.prisma.labCaseComment.findUnique({
where: { id: commentId },
select: { id: true, labCaseId: true, authorSide: true },
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
await this.assertLabCanComment(comment.labCaseId, labOrganizationId, actorUserId);
if (comment.authorSide !== LabCaseCommentSide.LAB) {
throw new ForbiddenException('Only lab comments can change visibility');
}
const updated = await this.prisma.labCaseComment.update({
where: { id: commentId },
data: { visibleToClinic },
include: commentInclude,
});
return { success: true, data: this.mapComment(updated, LabCaseCommentSide.LAB) };
}
// ---------- Clinic side (connection access is validated by caller) ----------
async listForClinic(caseId: string, clinicOrganizationId: string) {
await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
const comments = await this.fetchComments(caseId, { visibleOnly: true });
return {
success: true,
data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)),
};
}
async addForClinic(
caseId: string,
clinicOrganizationId: string,
actorUserId: string,
dto: CreateLabCaseCommentDto,
) {
await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
const created = await this.prisma.labCaseComment.create({
data: {
labCaseId: caseId,
authorUserId: actorUserId,
authorOrganizationId: clinicOrganizationId,
authorSide: LabCaseCommentSide.CLINIC,
body: dto.body.trim(),
// Clinic-authored comments are inherently visible to the clinic.
visibleToClinic: true,
},
include: commentInclude,
});
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
}
// ---------- Helpers ----------
private fetchComments(caseId: string, opts?: { visibleOnly?: boolean }) {
return this.prisma.labCaseComment.findMany({
where: {
labCaseId: caseId,
...(opts?.visibleOnly ? { visibleToClinic: true } : {}),
},
include: commentInclude,
orderBy: { createdAt: 'asc' },
});
}
private mapComment(comment: CommentWithRelations, viewerSide: LabCaseCommentSide) {
return {
id: comment.id,
body: comment.body,
authorSide: comment.authorSide,
authorName: comment.authorUser?.name ?? null,
authorOrganizationName: comment.authorOrganization?.name ?? null,
visibleToClinic: comment.visibleToClinic,
createdAt: comment.createdAt.toISOString(),
// Only lab viewers can toggle visibility, and only on lab-authored comments.
canToggleVisibility:
viewerSide === LabCaseCommentSide.LAB &&
comment.authorSide === LabCaseCommentSide.LAB,
};
}
private async assertLabCanComment(
caseId: string,
labOrganizationId: string,
actorUserId: string,
) {
const labCase = await this.prisma.labCase.findFirst({
where: {
id: caseId,
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
select: { id: true },
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
const membership = await this.prisma.membership.findFirst({
where: { userId: actorUserId, organizationId: labOrganizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
});
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
}
if (membership.isOwner) return;
const names = membership.permissions.map((p) => p.permission.name);
if (!names.includes('TAB_TASKS_EDIT')) {
throw new ForbiddenException('You do not have access to task comments');
}
}
private async assertClinicOwnsCase(caseId: string, clinicOrganizationId: string) {
const labCase = await this.prisma.labCase.findFirst({
where: {
id: caseId,
sentAt: { not: null },
treatment: { organizationId: clinicOrganizationId },
},
select: { id: true },
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
}
}

View File

@@ -19,6 +19,7 @@ import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.
import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto';
import { OrganizationService } from './organization.service';
import { ListLabCasesDto } from '../cases/dto/cases.dto';
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
/**
* Counterpart orgs (clinic↔lab).
@@ -157,6 +158,42 @@ export class OrganizationController {
);
}
@Get('connections/:connectionId/cases/:caseId/comments')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List clinic-visible comments for a connection case' })
listConnectionCaseComments(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('connectionId') connectionId: string,
@Param('caseId') caseId: string,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.listConnectionCaseComments(
req.user.id,
organizationId,
connectionId,
caseId,
);
}
@Post('connections/:connectionId/cases/:caseId/comments')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Reply to a connection case as the clinic' })
addConnectionCaseComment(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('connectionId') connectionId: string,
@Param('caseId') caseId: string,
@Body() dto: CreateLabCaseCommentDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.addConnectionCaseComment(
req.user.id,
organizationId,
connectionId,
caseId,
dto,
);
}
@Post('invitations/:invitationId/link')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' })

View File

@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { CasesModule } from '../cases/cases.module';
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
@Module({
imports: [CasesModule],
imports: [CasesModule, LabCaseCommentsModule],
controllers: [OrganizationController],
providers: [OrganizationService, PrismaService],
})

View File

@@ -11,6 +11,8 @@ import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { ListLabCasesDto } from '../cases/dto/cases.dto';
import { CasesService } from '../cases/cases.service';
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
@@ -33,6 +35,7 @@ export class OrganizationService {
constructor(
private readonly prisma: PrismaService,
private readonly casesService: CasesService,
private readonly commentsService: LabCaseCommentsService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -405,6 +408,55 @@ export class OrganizationService {
};
}
async listConnectionCaseComments(
userId: string,
organizationId: string,
connectionId: string,
caseId: string,
) {
const { clinicOrganizationId } = await this.resolveClinicConnection(
userId,
organizationId,
connectionId,
);
return this.commentsService.listForClinic(caseId, clinicOrganizationId);
}
async addConnectionCaseComment(
userId: string,
organizationId: string,
connectionId: string,
caseId: string,
dto: CreateLabCaseCommentDto,
) {
const { clinicOrganizationId } = await this.resolveClinicConnection(
userId,
organizationId,
connectionId,
);
return this.commentsService.addForClinic(caseId, clinicOrganizationId, userId, dto);
}
/**
* Clinic comment surfaces require the actor to belong to the clinic side of the connection.
* Only clinic-side members may read/reply to case comments from the connection history.
*/
private async resolveClinicConnection(
userId: string,
organizationId: string,
connectionId: string,
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const parties = await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
if (parties.clinicOrganizationId !== organizationId) {
throw new ForbiddenException('Only the clinic can comment on this case');
}
return parties;
}
/** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */
async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
const actor = await this.getActorMembership(userId, organizationId);

View File

@@ -1,13 +1,71 @@
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import {
IsBoolean,
IsDateString,
IsEnum,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { LabTaskStatus } from '@prisma/client';
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true' || value === '1') return true;
if (value === 'false' || value === '0') return false;
return value;
};
export class UpdateLabTaskDto {
@IsEnum(LabTaskStatus)
status: LabTaskStatus;
}
export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
export class ListLabTasksDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsUUID()
clinicOrganizationId?: string;
@IsOptional()
@IsEnum(LabTaskStatus)
status?: LabTaskStatus;
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
completed?: boolean;
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
important?: boolean;
@IsOptional()
@IsDateString()
sentFrom?: string;
@IsOptional()
@IsDateString()
sentTo?: string;
@IsOptional()
@IsIn(['date', 'status', 'clinic', 'patient', 'important'])
sortBy?: TaskSortField;
@IsOptional()
@IsIn(['asc', 'desc'])
sortDir?: 'asc' | 'desc';
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()

View File

@@ -13,7 +13,7 @@ export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
@ApiOperation({ summary: 'List lab tasks' })
list(@Query() query: ListLabTasksDto, @Req() req) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);

View File

@@ -6,14 +6,16 @@ import {
} from '@nestjs/common';
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 { normalizeTaskTeeth } from '../cases/lab-case-task.util';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
const taskListInclude = {
assignee: { select: { id: true, name: true, email: true } },
lastStatusChangedBy: { select: { id: true, name: true } },
labCase: {
include: {
treatment: {
@@ -48,35 +50,17 @@ export class TasksService {
) {
await this.assertCanReadTasks(actorUserId, labOrganizationId);
const membership = await this.getMembership(actorUserId, labOrganizationId);
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
}
const page = query.page ?? 1;
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
const skip = (page - 1) * limit;
const where: Prisma.LabCaseTaskWhereInput = {
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
...(membership.isOwner ? {} : { assigneeUserId: actorUserId }),
};
const where = this.buildListWhere(labOrganizationId, query);
const [items, total] = await Promise.all([
this.prisma.labCaseTask.findMany({
where,
include: taskListInclude,
orderBy: [
{ assignedAt: { sort: 'desc', nulls: 'first' } },
{ createdAt: 'desc' },
{ labCaseId: 'asc' },
{ priority: 'desc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
],
orderBy: this.buildOrderBy(query),
skip,
take: limit,
}),
@@ -114,11 +98,6 @@ export class TasksService {
) {
await this.assertCanEditTasks(actorUserId, labOrganizationId);
const membership = await this.getMembership(actorUserId, labOrganizationId);
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
}
const task = await this.prisma.labCaseTask.findFirst({
where: {
id: taskId,
@@ -134,14 +113,29 @@ export class TasksService {
throw new NotFoundException('Task not found');
}
if (!membership.isOwner && task.assigneeUserId !== actorUserId) {
throw new ForbiddenException('You can only update tasks assigned to you');
}
const updated = await this.prisma.$transaction(async (tx) => {
const result = await tx.labCaseTask.update({
where: { id: taskId },
data: {
status: dto.status,
lastStatusChangedByUserId: actorUserId,
lastStatusChangedAt: new Date(),
},
include: taskListInclude,
});
const updated = await this.prisma.labCaseTask.update({
where: { id: taskId },
data: { status: dto.status },
include: taskListInclude,
if (task.status !== dto.status) {
await tx.labCaseTaskStatusEvent.create({
data: {
taskId,
fromStatus: task.status,
toStatus: dto.status,
changedByUserId: actorUserId,
},
});
}
return result;
});
const locale = normalizeCatalogLocale(localeInput);
@@ -154,6 +148,100 @@ export class TasksService {
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
}
private buildListWhere(
labOrganizationId: string,
query: ListLabTasksDto,
): Prisma.LabCaseTaskWhereInput {
const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
if (query.sentFrom) {
const from = new Date(query.sentFrom);
if (Number.isNaN(from.getTime())) {
throw new BadRequestException('Invalid sentFrom date');
}
sentAtFilter.gte = from;
}
if (query.sentTo) {
const to = new Date(query.sentTo);
if (Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid sentTo date');
}
to.setHours(23, 59, 59, 999);
sentAtFilter.lte = to;
}
// Status: explicit status wins; completed=true/false narrows; otherwise no status filter.
let status: LabTaskStatus | undefined;
if (query.status) {
status = query.status;
} else if (query.completed === true) {
status = LabTaskStatus.COMPLETED;
} else if (query.completed === false) {
status = LabTaskStatus.IN_PROGRESS;
}
return {
labCase: {
sentAt: sentAtFilter,
sends: { some: { organizationId: labOrganizationId } },
...(query.clinicOrganizationId
? { treatment: { organizationId: query.clinicOrganizationId } }
: {}),
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
},
...(status !== undefined ? { status } : {}),
...(query.important !== undefined ? { isImportant: query.important } : {}),
};
}
private buildSearchWhere(q: string): Prisma.TreatmentWhereInput {
const orConditions: Prisma.PatientWhereInput[] = [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
];
const normalized = normalizeMobile(q);
if (normalized) {
orConditions.push({ mobile: normalized });
}
return {
OR: [
{ patient: { OR: orConditions } },
{ organization: { name: { contains: q, mode: 'insensitive' } } },
],
};
}
private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] {
const dir = query.sortDir ?? 'desc';
switch (query.sortBy) {
case 'status':
return [{ status: dir }, { createdAt: 'desc' }, { id: 'asc' }];
case 'clinic':
return [
{ labCase: { treatment: { organization: { name: dir } } } },
{ createdAt: 'desc' },
{ id: 'asc' },
];
case 'patient':
return [
{ labCase: { treatment: { patient: { lastName: dir } } } },
{ labCase: { treatment: { patient: { firstName: dir } } } },
{ id: 'asc' },
];
case 'important':
return [{ isImportant: dir }, { createdAt: 'desc' }, { id: 'asc' }];
case 'date':
default:
return [
{ labCase: { sentAt: dir } },
{ labCaseId: 'asc' },
{ treatmentDetailId: 'asc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
];
}
}
private mapTaskListItem(
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
prosthesisLabels: Map<string, string>,
@@ -161,21 +249,22 @@ export class TasksService {
return {
id: task.id,
labCaseId: task.labCaseId,
tooth: task.tooth,
treatmentDetailId: task.treatmentDetailId,
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,
priority: task.priority,
assignedAt: task.assignedAt?.toISOString() ?? null,
createdAt: task.createdAt.toISOString(),
assigneeUserId: task.assigneeUserId,
assignee: task.assignee
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
isImportant: task.isImportant,
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
lastStatusChangedBy: task.lastStatusChangedBy
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
: null,
createdAt: task.createdAt.toISOString(),
clinic: task.labCase.treatment.organization,
patient: {
id: task.labCase.treatment.patient.id,