feature: Phase3 - Task templates + generation on send
This commit is contained in:
@@ -11,6 +11,8 @@ import { StaffModule } from './modules/staff/staff.module';
|
||||
import { OrganizationModule } from './modules/organization/organization.module';
|
||||
import { AppointmentsModule } from './modules/appointments/appointments.module';
|
||||
import { TreatmentsModule } from './modules/treatments/treatments.module';
|
||||
import { CasesModule } from './modules/cases/cases.module';
|
||||
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,10 +21,12 @@ import { TreatmentsModule } from './modules/treatments/treatments.module';
|
||||
load: [configurations],
|
||||
}),
|
||||
PrismaModule, // ✅ ADD THIS
|
||||
TreatmentCatalogModule,
|
||||
AuthModule,
|
||||
PatientsModule,
|
||||
AppointmentsModule,
|
||||
TreatmentsModule,
|
||||
CasesModule,
|
||||
StaffModule,
|
||||
OrganizationModule,
|
||||
AdminModule.forRoot(),
|
||||
|
||||
25
backend/src/common/guards/lab-org.guard.ts
Normal file
25
backend/src/common/guards/lab-org.guard.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { assertLabOrganization } from '../../common/organization-type';
|
||||
|
||||
@Injectable()
|
||||
export class LabOrgGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<{ user?: { organizationId?: string } }>();
|
||||
const organizationId = request.user?.organizationId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new UnauthorizedException('Organization is not selected');
|
||||
}
|
||||
|
||||
await assertLabOrganization(this.prisma, organizationId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
|
||||
import { CreateAppointmentDto } from './dto/create-appointment.dto';
|
||||
import { ListAppointmentsDto } from './dto/list-appointments.dto';
|
||||
import { UpdateAppointmentDto } from './dto/update-appointment.dto';
|
||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
@@ -22,6 +23,7 @@ export class AppointmentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly staffWorkingHoursService: StaffWorkingHoursService,
|
||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -134,6 +136,7 @@ export class AppointmentsService {
|
||||
|
||||
await this.ensurePatientInOrg(dto.patientId, organizationId);
|
||||
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
|
||||
this.treatmentCatalog.assertKnownTreatmentType(dto.purpose);
|
||||
await this.ensureAppointmentWithinProviderWorkingHours(
|
||||
dto.providerUserId,
|
||||
organizationId,
|
||||
@@ -195,6 +198,8 @@ export class AppointmentsService {
|
||||
const providerUserId = dto.providerUserId ?? existing.providerUserId;
|
||||
const purpose = dto.purpose ?? existing.purpose;
|
||||
|
||||
this.treatmentCatalog.assertKnownTreatmentType(purpose);
|
||||
|
||||
await this.ensurePatientInOrg(patientId, organizationId);
|
||||
await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId);
|
||||
await this.ensureAppointmentWithinProviderWorkingHours(
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsDateString, IsIn, IsUUID } from 'class-validator';
|
||||
|
||||
const APPOINTMENT_PURPOSES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
|
||||
|
||||
export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
|
||||
import { IsDateString, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateAppointmentDto {
|
||||
@ApiProperty()
|
||||
@@ -22,7 +18,11 @@ export class CreateAppointmentDto {
|
||||
@IsDateString()
|
||||
endAt: string;
|
||||
|
||||
@ApiProperty({ enum: APPOINTMENT_PURPOSES })
|
||||
@IsIn([...APPOINTMENT_PURPOSES])
|
||||
purpose: AppointmentPurpose;
|
||||
@ApiProperty({
|
||||
description: 'Treatment type code from the treatment catalog',
|
||||
example: 'consultation',
|
||||
})
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
56
backend/src/modules/cases/cases.controller.ts
Normal file
56
backend/src/modules/cases/cases.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Query,
|
||||
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 { CasesService } from './cases.service';
|
||||
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
|
||||
|
||||
@ApiTags('cases')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard, LabOrgGuard)
|
||||
@Controller('cases')
|
||||
export class CasesController {
|
||||
constructor(private readonly casesService: CasesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List lab cases received by this organization' })
|
||||
list(@Query() query: ListLabCasesDto, @Req() req) {
|
||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||
return this.casesService.list(organizationId, req.user.id, query);
|
||||
}
|
||||
|
||||
@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) {
|
||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||
return this.casesService.getOne(id, organizationId, req.user.id);
|
||||
}
|
||||
|
||||
@Patch(':id/tasks/:taskId')
|
||||
@ApiOperation({ summary: 'Update task assignee or status' })
|
||||
updateTask(
|
||||
@Param('id') id: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: UpdateLabCaseTaskDto,
|
||||
@Req() req,
|
||||
) {
|
||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||
return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id);
|
||||
}
|
||||
}
|
||||
11
backend/src/modules/cases/cases.module.ts
Normal file
11
backend/src/modules/cases/cases.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||
import { CasesController } from './cases.controller';
|
||||
import { CasesService } from './cases.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CasesController],
|
||||
providers: [CasesService, PrismaService, LabOrgGuard],
|
||||
})
|
||||
export class CasesModule {}
|
||||
405
backend/src/modules/cases/cases.service.ts
Normal file
405
backend/src/modules/cases/cases.service.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
||||
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
|
||||
|
||||
const labCaseListInclude = {
|
||||
treatment: {
|
||||
include: {
|
||||
organization: { select: { id: true, name: true } },
|
||||
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
||||
appointment: { select: { startAt: true } },
|
||||
},
|
||||
},
|
||||
details: {
|
||||
include: {
|
||||
detail: {
|
||||
select: {
|
||||
id: true,
|
||||
treatmentType: true,
|
||||
teeth: true,
|
||||
comment: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sends: {
|
||||
orderBy: [{ sentAt: 'asc' as const }],
|
||||
include: { organization: { select: { id: true, name: true } } },
|
||||
},
|
||||
tasks: {
|
||||
orderBy: [
|
||||
{ tooth: 'asc' as const },
|
||||
{ treatmentType: 'asc' as const },
|
||||
{ stepOrder: 'asc' as const },
|
||||
],
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.LabCaseInclude;
|
||||
|
||||
@Injectable()
|
||||
export class CasesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
|
||||
async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) {
|
||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||
|
||||
if (query.treatmentType) {
|
||||
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
|
||||
}
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Prisma.LabCaseWhereInput = {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...(query.clinicOrganizationId
|
||||
? { treatment: { organizationId: query.clinicOrganizationId } }
|
||||
: {}),
|
||||
...(query.treatmentType
|
||||
? {
|
||||
details: {
|
||||
some: { detail: { treatmentType: query.treatmentType } },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(query.q?.trim()
|
||||
? this.buildSearchWhere(query.q.trim())
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.labCase.findMany({
|
||||
where,
|
||||
include: {
|
||||
treatment: {
|
||||
include: {
|
||||
organization: { select: { id: true, name: true } },
|
||||
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
||||
},
|
||||
},
|
||||
details: {
|
||||
include: {
|
||||
detail: { select: { treatmentType: true } },
|
||||
},
|
||||
},
|
||||
tasks: { select: { id: true, status: true } },
|
||||
},
|
||||
orderBy: [{ sentAt: 'desc' }],
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.labCase.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map((lc) => this.mapLabCaseListItem(lc)),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
|
||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
}
|
||||
|
||||
return { success: true, data: this.mapLabCaseDetail(labCase) };
|
||||
}
|
||||
|
||||
async updateTask(
|
||||
labCaseId: string,
|
||||
taskId: string,
|
||||
dto: UpdateLabCaseTaskDto,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||
|
||||
const task = await this.prisma.labCaseTask.findFirst({
|
||||
where: {
|
||||
id: taskId,
|
||||
labCaseId,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found');
|
||||
}
|
||||
|
||||
if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) {
|
||||
await this.ensureLabMember(dto.assigneeUserId, labOrganizationId);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.labCaseTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
...(dto.assigneeUserId !== undefined ? { assigneeUserId: dto.assigneeUserId } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
},
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTask(updated) };
|
||||
}
|
||||
|
||||
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 } } },
|
||||
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: memberships.map((m) => ({
|
||||
userId: m.user.id,
|
||||
name: m.user.name,
|
||||
email: m.user.email,
|
||||
isOwner: m.isOwner,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSearchWhere(q: string): Prisma.LabCaseWhereInput {
|
||||
const orConditions: Prisma.LabCaseWhereInput[] = [
|
||||
{
|
||||
treatment: {
|
||||
patient: {
|
||||
OR: [
|
||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
treatment: {
|
||||
organization: { name: { contains: q, mode: 'insensitive' } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const normalized = normalizeMobile(q);
|
||||
if (normalized) {
|
||||
orConditions.push({
|
||||
treatment: { patient: { mobile: normalized } },
|
||||
});
|
||||
}
|
||||
|
||||
return { OR: orConditions };
|
||||
}
|
||||
|
||||
private mapLabCaseListItem(lc: {
|
||||
id: string;
|
||||
sentAt: Date | null;
|
||||
treatment: {
|
||||
organization: { id: string; name: string };
|
||||
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
||||
};
|
||||
details: Array<{ detail: { treatmentType: string } }>;
|
||||
tasks: Array<{ id: string; status: LabTaskStatus }>;
|
||||
}) {
|
||||
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
|
||||
const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;
|
||||
|
||||
return {
|
||||
id: lc.id,
|
||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||
clinic: lc.treatment.organization,
|
||||
patient: {
|
||||
id: lc.treatment.patient.id,
|
||||
firstName: lc.treatment.patient.firstName,
|
||||
lastName: lc.treatment.patient.lastName,
|
||||
mobile: lc.treatment.patient.mobile,
|
||||
},
|
||||
treatmentTypes,
|
||||
taskProgress: {
|
||||
completed: completedTasks,
|
||||
total: lc.tasks.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) {
|
||||
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
|
||||
const tasksByTooth = this.groupTasksByTooth(lc.tasks);
|
||||
|
||||
return {
|
||||
id: lc.id,
|
||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||
labComment: lc.labComment,
|
||||
clinic: lc.treatment.organization,
|
||||
patient: lc.treatment.patient,
|
||||
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
||||
treatmentTypes,
|
||||
details: lc.details.map((link) => ({
|
||||
id: link.detail.id,
|
||||
treatmentType: link.detail.treatmentType,
|
||||
teeth: normalizeTeeth(link.detail.teeth),
|
||||
comment: link.detail.comment,
|
||||
})),
|
||||
sends: lc.sends.map((s) => ({
|
||||
organizationId: s.organizationId,
|
||||
organizationName: s.organization.name,
|
||||
sentAt: s.sentAt.toISOString(),
|
||||
})),
|
||||
tasks: lc.tasks.map((t) => this.mapTask(t)),
|
||||
tasksByTooth,
|
||||
taskProgress: {
|
||||
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
|
||||
total: lc.tasks.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private groupTasksByTooth(
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
tooth: string;
|
||||
treatmentType: string;
|
||||
stepOrder: number;
|
||||
stepLabel: string;
|
||||
status: LabTaskStatus;
|
||||
assigneeUserId: string | null;
|
||||
assignee: { id: string; name: string; email: string } | null;
|
||||
}>,
|
||||
) {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
tooth: string;
|
||||
treatmentType: string;
|
||||
tasks: ReturnType<CasesService['mapTask']>[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const task of tasks) {
|
||||
const key = `${task.tooth}:${task.treatmentType}`;
|
||||
const entry = groups.get(key) ?? {
|
||||
tooth: task.tooth,
|
||||
treatmentType: task.treatmentType,
|
||||
tasks: [],
|
||||
};
|
||||
entry.tasks.push(this.mapTask(task));
|
||||
groups.set(key, entry);
|
||||
}
|
||||
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
private mapTask(task: {
|
||||
id: string;
|
||||
tooth: string;
|
||||
treatmentType: string;
|
||||
stepOrder: number;
|
||||
stepLabel: string;
|
||||
status: LabTaskStatus;
|
||||
assigneeUserId: string | null;
|
||||
assignee: { id: string; name: string; email: string } | null;
|
||||
}) {
|
||||
return {
|
||||
id: task.id,
|
||||
tooth: task.tooth,
|
||||
treatmentType: task.treatmentType,
|
||||
stepOrder: task.stepOrder,
|
||||
stepLabel: task.stepLabel,
|
||||
status: task.status,
|
||||
assigneeUserId: task.assigneeUserId,
|
||||
assignee: task.assignee
|
||||
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureLabMember(userId: string, labOrganizationId: string) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId: labOrganizationId, isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new BadRequestException('Assignee must be an active member of this lab');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCanReadCases(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
if (m.isOwner) return;
|
||||
const names = m.permissions.map((p) => p.permission.name);
|
||||
if (names.includes('TAB_CASES_READ') || names.includes('TAB_CASES_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to cases');
|
||||
}
|
||||
|
||||
private async assertCanEditCases(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
if (m.isOwner) return;
|
||||
const names = m.permissions.map((p) => p.permission.name);
|
||||
if (names.includes('TAB_CASES_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You cannot update cases');
|
||||
}
|
||||
|
||||
private async getMembership(userId: string, organizationId: string) {
|
||||
return this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId, isActive: true },
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
41
backend/src/modules/cases/dto/cases.dto.ts
Normal file
41
backend/src/modules/cases/dto/cases.dto.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
|
||||
import { LabTaskStatus } from '@prisma/client';
|
||||
|
||||
export class UpdateLabCaseTaskDto {
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null)
|
||||
@IsUUID()
|
||||
assigneeUserId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(LabTaskStatus)
|
||||
status?: LabTaskStatus;
|
||||
}
|
||||
|
||||
export class ListLabCasesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clinicOrganizationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
treatmentType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit = 20;
|
||||
}
|
||||
91
backend/src/modules/cases/lab-case-task.generator.ts
Normal file
91
backend/src/modules/cases/lab-case-task.generator.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
||||
|
||||
type TransactionClient = Prisma.TransactionClient;
|
||||
|
||||
export async function generateLabCaseTasks(
|
||||
tx: TransactionClient,
|
||||
labCaseId: string,
|
||||
): Promise<number> {
|
||||
const existingCount = await tx.labCaseTask.count({ where: { labCaseId } });
|
||||
if (existingCount > 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const labCase = await tx.labCase.findUnique({
|
||||
where: { id: labCaseId },
|
||||
include: {
|
||||
details: {
|
||||
include: {
|
||||
detail: {
|
||||
select: { id: true, treatmentType: true, teeth: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!labCase?.details.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const treatmentTypeCodes = [...new Set(labCase.details.map((d) => d.detail.treatmentType))];
|
||||
|
||||
const labDependentTypes = await tx.treatmentType.findMany({
|
||||
where: { code: { in: treatmentTypeCodes }, labDependent: true },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
|
||||
if (labDependentTypes.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const labDependentCodes = new Set(labDependentTypes.map((t) => t.code));
|
||||
|
||||
const workflowSteps = await tx.treatmentWorkflowStep.findMany({
|
||||
where: { treatmentTypeId: { in: labDependentTypes.map((t) => t.id) } },
|
||||
orderBy: [{ treatmentTypeId: 'asc' }, { stepOrder: 'asc' }],
|
||||
include: { treatmentType: { select: { code: true } } },
|
||||
});
|
||||
|
||||
const stepsByTypeCode = new Map<string, { stepOrder: number; label: string }[]>();
|
||||
for (const step of workflowSteps) {
|
||||
const code = step.treatmentType.code;
|
||||
const list = stepsByTypeCode.get(code) ?? [];
|
||||
list.push({ stepOrder: step.stepOrder, label: step.label });
|
||||
stepsByTypeCode.set(code, list);
|
||||
}
|
||||
|
||||
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
|
||||
|
||||
for (const link of labCase.details) {
|
||||
const detail = link.detail;
|
||||
if (!labDependentCodes.has(detail.treatmentType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const teeth = normalizeTeeth(detail.teeth);
|
||||
const typeSteps = stepsByTypeCode.get(detail.treatmentType) ?? [];
|
||||
|
||||
for (const tooth of teeth) {
|
||||
for (const step of typeSteps) {
|
||||
taskRows.push({
|
||||
labCaseId,
|
||||
treatmentDetailId: detail.id,
|
||||
tooth,
|
||||
treatmentType: detail.treatmentType,
|
||||
stepOrder: step.stepOrder,
|
||||
stepLabel: step.label,
|
||||
status: LabTaskStatus.PENDING,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (taskRows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await tx.labCaseTask.createMany({ data: taskRows });
|
||||
return taskRows.length;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TreatmentCatalogService } from './treatment-catalog.service';
|
||||
|
||||
@ApiTags('treatment-catalog')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('treatment-catalog')
|
||||
export class TreatmentCatalogController {
|
||||
constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List treatment types from the catalog (data-driven)' })
|
||||
list() {
|
||||
return {
|
||||
success: true,
|
||||
data: this.treatmentCatalogService.list(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { TreatmentCatalogController } from './treatment-catalog.controller';
|
||||
import { TreatmentCatalogService } from './treatment-catalog.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [TreatmentCatalogController],
|
||||
providers: [TreatmentCatalogService, PrismaService],
|
||||
exports: [TreatmentCatalogService],
|
||||
})
|
||||
export class TreatmentCatalogModule {}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
|
||||
export type TreatmentTypeCatalogEntry = {
|
||||
id: string;
|
||||
code: string;
|
||||
labDependent: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TreatmentCatalogService implements OnModuleInit {
|
||||
private loaded = false;
|
||||
private byCode = new Map<string, TreatmentTypeCatalogEntry>();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const rows = await this.prisma.treatmentType.findMany({
|
||||
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
|
||||
select: { id: true, code: true, labDependent: true, sortOrder: true },
|
||||
});
|
||||
|
||||
this.byCode = new Map(rows.map((row) => [row.code, row]));
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
list(): TreatmentTypeCatalogEntry[] {
|
||||
this.ensureLoaded();
|
||||
return [...this.byCode.values()];
|
||||
}
|
||||
|
||||
getByCode(code: string): TreatmentTypeCatalogEntry | undefined {
|
||||
this.ensureLoaded();
|
||||
return this.byCode.get(code);
|
||||
}
|
||||
|
||||
assertKnownTreatmentType(code: string): TreatmentTypeCatalogEntry {
|
||||
const entry = this.getByCode(code);
|
||||
if (!entry) {
|
||||
throw new BadRequestException(`Unknown treatment type: ${code}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
assertLabDependentTreatmentType(code: string): TreatmentTypeCatalogEntry {
|
||||
const entry = this.assertKnownTreatmentType(code);
|
||||
if (!entry.labDependent) {
|
||||
throw new BadRequestException(
|
||||
`Treatment type "${code}" is completed in the clinic and cannot be sent to a lab`,
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
isLabDependent(code: string): boolean {
|
||||
return this.getByCode(code)?.labDependent ?? false;
|
||||
}
|
||||
|
||||
private ensureLoaded() {
|
||||
if (!this.loaded) {
|
||||
throw new Error('Treatment catalog is not loaded yet');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
@@ -10,8 +9,6 @@ import {
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
|
||||
|
||||
export class SaveTreatmentDetailDto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@@ -21,7 +18,8 @@ export class SaveTreatmentDetailDto {
|
||||
@IsUUID()
|
||||
id?: string;
|
||||
|
||||
@IsIn(TREATMENT_TYPES)
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
treatmentType: string;
|
||||
|
||||
@IsArray()
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { TreatmentStatus } from '@prisma/client';
|
||||
|
||||
const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
|
||||
|
||||
export type TreatmentTypeValue = (typeof TREATMENT_TYPES)[number];
|
||||
|
||||
export function isTreatmentType(value: string): value is TreatmentTypeValue {
|
||||
return (TREATMENT_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
const FDI_TOOTH_IDS = new Set([
|
||||
'11', '12', '13', '14', '15', '16', '17', '18',
|
||||
'21', '22', '23', '24', '25', '26', '27', '28',
|
||||
|
||||
@@ -9,13 +9,14 @@ import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
|
||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||
import {
|
||||
SaveTreatmentDraftDto,
|
||||
SaveTreatmentLabCasesDto,
|
||||
} from './dto/treatment.dto';
|
||||
import {
|
||||
generateTreatmentTitle,
|
||||
isTreatmentType,
|
||||
mapTreatmentStatusForApi,
|
||||
normalizeTeeth,
|
||||
} from './treatment.utils';
|
||||
@@ -61,7 +62,10 @@ const treatmentInclude = {
|
||||
export class TreatmentsService {
|
||||
private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments');
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
@@ -163,9 +167,7 @@ export class TreatmentsService {
|
||||
);
|
||||
|
||||
for (const d of dto.details) {
|
||||
if (!isTreatmentType(d.treatmentType)) {
|
||||
throw new BadRequestException(`Invalid treatment type: ${d.treatmentType}`);
|
||||
}
|
||||
this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType);
|
||||
}
|
||||
|
||||
const normalizedDetails = dto.details.map((d, index) => ({
|
||||
@@ -328,12 +330,16 @@ export class TreatmentsService {
|
||||
|
||||
const details = await this.prisma.treatmentDetail.findMany({
|
||||
where: { treatmentId: treatment.id, id: { in: detailIds } },
|
||||
select: { id: true },
|
||||
select: { id: true, treatmentType: true },
|
||||
});
|
||||
if (details.length !== uniqueDetailIds.size) {
|
||||
throw new BadRequestException('One or more treatment details were not found');
|
||||
}
|
||||
|
||||
for (const detail of details) {
|
||||
this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType);
|
||||
}
|
||||
|
||||
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
|
||||
|
||||
for (const lc of dto.labCases) {
|
||||
@@ -470,6 +476,8 @@ export class TreatmentsService {
|
||||
data: { sentAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
await generateLabCaseTasks(tx, labCaseId);
|
||||
});
|
||||
|
||||
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
||||
|
||||
Reference in New Issue
Block a user