improvement: v1 standalone treatment/case creation made possible.

This commit is contained in:
2026-08-19 15:05:58 +03:30
parent e5c39c6b7e
commit 8bfa8c88fe
39 changed files with 3296 additions and 387 deletions

View File

@@ -1,6 +1,7 @@
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsOptional,
@@ -133,3 +134,24 @@ export class ListPatientTreatmentHistoryDto {
@IsOptional()
limit?: number;
}
export class CreateStandaloneTreatmentDto {
@IsOptional()
@IsUUID()
patientId?: string;
@IsOptional()
@IsBoolean()
walkIn?: boolean;
@IsDateString()
treatmentAt: string;
}
export class ListDayTreatmentsDto {
@IsDateString()
from: string;
@IsDateString()
to: string;
}

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
@@ -24,6 +25,8 @@ import {
SaveTreatmentDraftDto,
SaveTreatmentLabCasesDto,
UpdateLabCaseDueDateDto,
CreateStandaloneTreatmentDto,
ListDayTreatmentsDto,
} from './dto/treatment.dto';
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
@@ -83,6 +86,31 @@ export class TreatmentsController {
);
}
@Get('day')
@ApiOperation({ summary: 'Standalone (no appointment) treatments for a local day range' })
listDayStandalone(
@Query() query: ListDayTreatmentsDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.listDayStandalone(
query.from,
query.to,
organizationId,
req.user.id,
);
}
@Post()
@ApiOperation({ summary: 'Create a treatment without an appointment (TAB_TREATMENT_EDIT)' })
createStandalone(
@Body() dto: CreateStandaloneTreatmentDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.createStandalone(dto, organizationId, req.user.id);
}
@Get('appointments/:appointmentId/draft')
@ApiOperation({ summary: 'Get draft treatment for an appointment (TAB_TREATMENT_READ)' })
getDraft(
@@ -268,4 +296,84 @@ export class TreatmentsController {
dto,
);
}
@Get(':treatmentId/draft')
@ApiOperation({ summary: 'Get a treatment draft by treatment id (TAB_TREATMENT_READ)' })
getDraftByTreatment(
@Param('treatmentId') treatmentId: string,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.getDraftForTreatment(
treatmentId,
organizationId,
req.user.id,
);
}
@Put(':treatmentId/draft')
@ApiOperation({ summary: 'Save draft treatment details by treatment id (TAB_TREATMENT_EDIT)' })
saveDraftByTreatment(
@Param('treatmentId') treatmentId: string,
@Body() dto: SaveTreatmentDraftDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveDraftForTreatment(
treatmentId,
dto,
organizationId,
req.user.id,
);
}
@Put(':treatmentId/lab-cases')
@ApiOperation({ summary: 'Save lab case groupings by treatment id (TAB_TREATMENT_EDIT)' })
saveLabCasesByTreatment(
@Param('treatmentId') treatmentId: string,
@Body() dto: SaveTreatmentLabCasesDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveLabCasesForTreatment(
treatmentId,
dto,
organizationId,
req.user.id,
);
}
@Post(':treatmentId/details/:detailClientKey/attachments')
@ApiOperation({ summary: 'Upload attachments for a standalone treatment detail' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FilesInterceptor('files', 20, {
storage: memoryStorage(),
}),
)
uploadDetailAttachmentsByTreatment(
@Param('treatmentId') treatmentId: string,
@Param('detailClientKey') detailClientKey: string,
@UploadedFiles() files: Express.Multer.File[],
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.uploadDetailAttachmentsForTreatment(
treatmentId,
detailClientKey,
files,
organizationId,
req.user.id,
);
}
@Delete(':treatmentId')
@ApiOperation({ summary: 'Delete an empty standalone treatment (no details)' })
deleteStandalone(
@Param('treatmentId') treatmentId: string,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.deleteStandalone(treatmentId, organizationId, req.user.id);
}
}

View File

@@ -16,6 +16,7 @@ import {
SaveTreatmentDraftDto,
SaveTreatmentLabCasesDto,
UpdateLabCaseDueDateDto,
CreateStandaloneTreatmentDto,
} from './dto/treatment.dto';
import {
isLabCaseFullyCompleted,
@@ -36,6 +37,7 @@ import {
isActorTreatmentProvider,
treatmentProviderScopeWhere,
} from '../../common/treatment-provider-scope';
import { ensureWalkInPatient } from '../../common/walk-in-patient';
const sentLabCaseInclude = {
treatment: {
@@ -67,6 +69,9 @@ const sentLabCaseInclude = {
type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseInclude }>;
const treatmentInclude = {
patient: {
select: { id: true, firstName: true, lastName: true, isWalkIn: true },
},
details: {
orderBy: [{ sortOrder: 'asc' as const }],
include: {
@@ -192,6 +197,108 @@ export class TreatmentsService {
return { success: true, data: items.map((t) => this.mapTreatment(t)) };
}
async createStandalone(
dto: CreateStandaloneTreatmentDto,
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const walkIn = Boolean(dto.walkIn);
if (!walkIn && !dto.patientId) {
throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST);
}
const treatmentAt = new Date(dto.treatmentAt);
if (Number.isNaN(treatmentAt.getTime())) {
throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST);
}
let patientId: string;
if (walkIn) {
const sentinel = await ensureWalkInPatient(this.prisma, organizationId);
patientId = sentinel.id;
} else {
await this.ensurePatientExists(dto.patientId!);
const patient = await this.prisma.patient.findUnique({
where: { id: dto.patientId! },
select: { isWalkIn: true },
});
if (patient?.isWalkIn) {
throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST);
}
patientId = dto.patientId!;
}
const treatment = await this.prisma.treatment.create({
data: {
organizationId,
patientId,
appointmentId: null,
providerUserId: actorUserId,
title: generateTreatmentTitle([]),
treatmentAt,
},
include: treatmentInclude,
});
return { success: true, data: this.mapTreatment(treatment) };
}
async deleteStandalone(
treatmentId: string,
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const treatment = await this.ensureTreatmentProvider(
treatmentId,
organizationId,
actorUserId,
);
if (treatment.appointmentId) {
throw new AppException(ErrorCode.TREATMENT_NOT_STANDALONE, HttpStatus.CONFLICT);
}
const detailCount = await this.prisma.treatmentDetail.count({
where: { treatmentId },
});
if (detailCount > 0) {
throw new AppException(ErrorCode.TREATMENT_HAS_DETAILS, HttpStatus.CONFLICT);
}
await this.prisma.treatment.delete({ where: { id: treatmentId } });
return { success: true };
}
async listDayStandalone(
fromIso: string,
toIso: string,
organizationId: string,
actorUserId: string,
) {
await this.assertCanReadTreatment(actorUserId, organizationId);
const from = new Date(fromIso);
const to = new Date(toIso);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to <= from) {
throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST);
}
const items = await this.prisma.treatment.findMany({
where: {
organizationId,
appointmentId: null,
treatmentAt: { gte: from, lt: to },
...treatmentProviderScopeWhere(actorUserId),
},
include: treatmentInclude,
orderBy: [{ treatmentAt: 'asc' }, { createdAt: 'asc' }],
});
return { success: true, data: items.map((t) => this.mapTreatment(t)) };
}
async listPatientLabCases(
patientId: string,
organizationId: string,
@@ -322,16 +429,20 @@ export class TreatmentsService {
}))
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
const patient = lc.treatment.patient;
const treatment = lc.treatment;
if (!treatment) {
throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const patient = treatment.patient;
return {
labCaseId: lc.id,
patientId: patient.id,
patientFirstName: patient.firstName,
patientLastName: patient.lastName,
treatmentId: lc.treatment.id,
appointmentId: lc.treatment.appointmentId,
treatmentAt: lc.treatment.treatmentAt.toISOString(),
treatmentId: treatment.id,
appointmentId: treatment.appointmentId,
treatmentAt: treatment.treatmentAt.toISOString(),
detailClientId: detail?.clientKey ?? detailLink?.treatmentDetailId ?? '',
teeth: detailTeeth,
prosthesisGroups,
@@ -385,6 +496,15 @@ export class TreatmentsService {
return { success: true, data: treatment ? this.mapTreatment(treatment) : null };
}
async getDraftForTreatment(
treatmentId: string,
organizationId: string,
actorUserId: string,
) {
const treatment = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
return { success: true, data: this.mapTreatment(treatment) };
}
async saveDraftForAppointment(
appointmentId: string,
dto: SaveTreatmentDraftDto,
@@ -398,11 +518,78 @@ export class TreatmentsService {
actorUserId,
);
for (const d of dto.details) {
const existing = await this.prisma.treatment.findFirst({
where: { appointmentId: appointment.id, organizationId },
select: { id: true },
});
const treatmentId = existing
? existing.id
: (
await this.prisma.treatment.create({
data: {
organizationId,
patientId: appointment.patientId,
appointmentId: appointment.id,
providerUserId: actorUserId,
title: generateTreatmentTitle([]),
treatmentAt: appointment.startAt,
},
select: { id: true },
})
).id;
const saved = await this.persistDraftDetails({
treatmentId,
organizationId,
actorUserId,
dto,
pendingAppointmentId: appointment.id,
treatmentAt: appointment.startAt,
patientId: appointment.patientId,
});
return { success: true, data: this.mapTreatment(saved) };
}
async saveDraftForTreatment(
treatmentId: string,
dto: SaveTreatmentDraftDto,
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const existing = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
const saved = await this.persistDraftDetails({
treatmentId,
organizationId,
actorUserId,
dto,
pendingAppointmentId: existing.appointmentId,
pendingTreatmentId: existing.appointmentId ? null : existing.id,
treatmentAt: existing.appointmentId ? existing.treatmentAt : existing.treatmentAt,
patientId: existing.patientId,
});
return { success: true, data: this.mapTreatment(saved) };
}
private async persistDraftDetails(args: {
treatmentId: string;
organizationId: string;
actorUserId: string;
dto: SaveTreatmentDraftDto;
pendingAppointmentId: string | null;
pendingTreatmentId?: string | null;
treatmentAt: Date;
patientId: string;
}) {
for (const d of args.dto.details) {
this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType);
}
const normalizedDetails = dto.details.map((d, index) => {
const normalizedDetails = args.dto.details.map((d, index) => {
const teeth = normalizeTeeth(d.teeth);
const toothSelectionGroups = normalizeToothSelectionGroups(
d.toothSelectionGroups,
@@ -422,41 +609,23 @@ export class TreatmentsService {
normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })),
);
const treatment = await this.prisma.$transaction(async (tx) => {
const existing = await tx.treatment.findFirst({
where: { appointmentId: appointment.id, organizationId },
select: { id: true },
return this.prisma.$transaction(async (tx) => {
const saved = await tx.treatment.update({
where: { id: args.treatmentId },
data: {
title,
treatmentAt: args.treatmentAt,
patientId: args.patientId,
providerUserId: args.actorUserId,
},
});
const saved = existing
? await tx.treatment.update({
where: { id: existing.id },
data: {
title,
treatmentAt: appointment.startAt,
patientId: appointment.patientId,
providerUserId: actorUserId,
},
})
: await tx.treatment.create({
data: {
organizationId,
patientId: appointment.patientId,
appointmentId: appointment.id,
providerUserId: actorUserId,
title,
treatmentAt: appointment.startAt,
},
});
const keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[];
const existingDetails = existing
? await tx.treatmentDetail.findMany({
where: { treatmentId: saved.id },
select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } },
})
: [];
const existingDetails = await tx.treatmentDetail.findMany({
where: { treatmentId: saved.id },
select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } },
});
const lockedDetailIds = new Set(
existingDetails
@@ -504,11 +673,14 @@ export class TreatmentsService {
});
const allowedAttachmentIds = new Set(d.attachmentIds);
const pendingWhere: Prisma.TreatmentDetailAttachmentWhereInput = {
detailClientKey: d.clientId,
...(args.pendingAppointmentId
? { appointmentId: args.pendingAppointmentId }
: { treatmentId: args.pendingTreatmentId ?? saved.id }),
};
const pendingAttachments = await tx.treatmentDetailAttachment.findMany({
where: {
appointmentId: appointment.id,
detailClientKey: d.clientId,
},
where: pendingWhere,
});
for (const attachment of pendingAttachments) {
@@ -517,7 +689,12 @@ export class TreatmentsService {
} else {
await tx.treatmentDetailAttachment.update({
where: { id: attachment.id },
data: { detailId: row.id, appointmentId: null, detailClientKey: null },
data: {
detailId: row.id,
appointmentId: null,
treatmentId: null,
detailClientKey: null,
},
});
}
}
@@ -535,8 +712,6 @@ export class TreatmentsService {
include: treatmentInclude,
});
});
return { success: true, data: this.mapTreatment(treatment) };
}
async saveLabCasesForAppointment(
@@ -561,6 +736,20 @@ export class TreatmentsService {
throw new AppException(ErrorCode.TREATMENT_SAVE_DETAILS_BEFORE_LAB, HttpStatus.NOT_FOUND);
}
return this.saveLabCasesForTreatment(treatment.id, dto, organizationId, actorUserId);
}
async saveLabCasesForTreatment(
treatmentId: string,
dto: SaveTreatmentLabCasesDto,
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
const treatment = { id: treatmentId };
const detailIds = dto.labCases.map((lc) => lc.treatmentDetailId);
const uniqueDetailIds = new Set(detailIds);
if (uniqueDetailIds.size !== detailIds.length) {
@@ -658,6 +847,7 @@ export class TreatmentsService {
: await tx.labCase.create({
data: {
treatmentId: treatment.id,
origin: 'CLINIC_DISPATCH',
clientKey: lc.clientId,
sortOrder: index,
destinationOrganizationId: lc.destinationOrganizationId ?? null,
@@ -679,6 +869,8 @@ export class TreatmentsService {
data: lc.toothProsthesis.map((tp) => ({
labCaseId: row.id,
treatmentDetailId: tp.treatmentDetailId,
sourceKey: tp.treatmentDetailId,
treatmentType: detailById.get(tp.treatmentDetailId)?.treatmentType ?? 'prosthesis',
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId?.trim() || '',
@@ -763,7 +955,17 @@ export class TreatmentsService {
throw new AppException(ErrorCode.TREATMENT_CASE_ONE_DETAIL, HttpStatus.BAD_REQUEST);
}
assertCompleteToothProsthesisMap(labCase);
if (!labCase.treatment) {
throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
assertCompleteToothProsthesisMap({
details: labCase.details,
toothProsthesis: labCase.toothProsthesis.filter(
(row): row is typeof row & { treatmentDetailId: string } =>
Boolean(row.treatmentDetailId),
),
});
if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
throw new AppException(ErrorCode.TREATMENT_ONLY_PROVIDER_SEND, HttpStatus.FORBIDDEN);
@@ -1011,6 +1213,72 @@ export class TreatmentsService {
return { success: true, data: created };
}
async uploadDetailAttachmentsForTreatment(
treatmentId: string,
detailClientKey: string,
files: Express.Multer.File[],
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
if (!detailClientKey?.trim()) {
throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST);
}
if (!files?.length) {
throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST);
}
const existingDetail = await this.prisma.treatmentDetail.findFirst({
where: {
clientKey: detailClientKey,
treatmentId,
},
select: {
labCaseLink: {
select: { labCase: { select: { sentAt: true } } },
},
},
});
if (existingDetail?.labCaseLink?.labCase.sentAt) {
throw new AppException(ErrorCode.TREATMENT_DETAIL_SENT, HttpStatus.CONFLICT);
}
const orgDir = join(this.uploadRoot, organizationId);
mkdirSync(orgDir, { recursive: true });
const created: {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}[] = [];
for (const file of files) {
const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`;
const storagePath = join(orgDir, storageName);
const { writeFileSync } = await import('fs');
writeFileSync(storagePath, file.buffer);
const attachment = await this.prisma.treatmentDetailAttachment.create({
data: {
treatmentId,
detailClientKey,
fileName: file.originalname,
mimeType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
storagePath,
},
});
created.push(this.mapAttachment(attachment));
}
return { success: true, data: created };
}
async streamAttachmentFile(
attachmentId: string,
organizationId: string,
@@ -1031,6 +1299,7 @@ export class TreatmentsService {
},
},
{ appointmentId: { not: null } },
{ treatmentId: { not: null } },
],
},
include: {
@@ -1060,6 +1329,20 @@ export class TreatmentsService {
}
}
if (!attachment.detail && attachment.treatmentId) {
const treatment = await this.prisma.treatment.findFirst({
where: {
id: attachment.treatmentId,
organizationId,
providerUserId: actorUserId,
},
select: { id: true },
});
if (!treatment) {
throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
if (!existsSync(attachment.storagePath)) {
throw new AppException(ErrorCode.TREATMENT_FILE_UNAVAILABLE, HttpStatus.NOT_FOUND);
}
@@ -1075,8 +1358,15 @@ export class TreatmentsService {
id: string;
patientId: string;
appointmentId: string | null;
providerUserId?: string;
title: string;
treatmentAt: Date;
patient?: {
id: string;
firstName: string;
lastName: string;
isWalkIn: boolean;
};
details: Array<{
id: string;
clientKey: string | null;
@@ -1127,8 +1417,17 @@ export class TreatmentsService {
id: treatment.id,
patientId: treatment.patientId,
appointmentId: treatment.appointmentId,
providerUserId: treatment.providerUserId ?? null,
title: treatment.title,
treatmentAt: treatment.treatmentAt.toISOString(),
patient: treatment.patient
? {
id: treatment.patient.id,
firstName: treatment.patient.firstName,
lastName: treatment.patient.lastName,
isWalkIn: treatment.patient.isWalkIn,
}
: null,
details: treatment.details.map((d) => this.mapDetail(d)),
labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)),
documents,
@@ -1209,7 +1508,7 @@ export class TreatmentsService {
organization?: { id: string; name: string };
}>;
toothProsthesis?: Array<{
treatmentDetailId: string;
treatmentDetailId: string | null;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
@@ -1319,6 +1618,24 @@ export class TreatmentsService {
}
}
private async ensureTreatmentProvider(
treatmentId: string,
organizationId: string,
actorUserId: string,
) {
const treatment = await this.prisma.treatment.findFirst({
where: { id: treatmentId, organizationId },
include: treatmentInclude,
});
if (!treatment) {
throw new AppException(ErrorCode.TREATMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (!isActorTreatmentProvider(treatment, actorUserId)) {
throw new AppException(ErrorCode.TREATMENT_ONLY_PROVIDER_SEND, HttpStatus.FORBIDDEN);
}
return treatment;
}
private async ensureAppointmentProvider(
appointmentId: string,
organizationId: string,