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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
60
backend/prisma/regenerate-lab-tasks.ts
Normal file
60
backend/prisma/regenerate-lab-tasks.ts
Normal 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();
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[],
|
||||
|
||||
11
backend/src/modules/cases/lab-case-task.util.ts
Normal file
11
backend/src/modules/cases/lab-case-task.util.ts
Normal 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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user