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

@@ -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,