feature: a minimal v1 backend implemented for treatments feature.
This commit is contained in:
@@ -10,6 +10,7 @@ import { PatientsModule } from './modules/patients/patients.module';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -21,6 +22,7 @@ import { AppointmentsModule } from './modules/appointments/appointments.module';
|
||||
AuthModule,
|
||||
PatientsModule,
|
||||
AppointmentsModule,
|
||||
TreatmentsModule,
|
||||
StaffModule,
|
||||
OrganizationModule,
|
||||
AdminModule.forRoot(),
|
||||
|
||||
@@ -220,6 +220,9 @@ export class AppointmentsService {
|
||||
if (names.includes('TAB_TREATMENT_EDIT')) {
|
||||
return;
|
||||
}
|
||||
if (names.includes('TAB_TREATMENT_READ')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to appointments');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { IsDateString, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateTreatmentHistoryDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
status: string;
|
||||
|
||||
@IsDateString()
|
||||
treatmentAt: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
tooth?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
totalCost?: number;
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { CreatePatientDto } from './dto/create-patient.dto';
|
||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||
import { UpdatePatientDto } from './dto/update-patient.dto';
|
||||
import { PatientsService } from './patients.service';
|
||||
import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto';
|
||||
|
||||
@ApiTags('patients')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@@ -52,26 +51,4 @@ export class PatientsController {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.update(id, updatePatientDto, organizationId);
|
||||
}
|
||||
|
||||
@Get(':id/treatments')
|
||||
@ApiOperation({ summary: 'Get patient treatment history' })
|
||||
findTreatments(
|
||||
@Param('id') id: string,
|
||||
@Query('limit', new ParseIntPipe({ optional: true })) limit = 20,
|
||||
@Req() req,
|
||||
) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.findTreatments(id, organizationId, limit);
|
||||
}
|
||||
|
||||
@Post(':id/treatments')
|
||||
@ApiOperation({ summary: 'Add treatment history item for a patient' })
|
||||
addTreatment(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateTreatmentHistoryDto,
|
||||
@Req() req,
|
||||
) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.addTreatment(id, dto, organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { CreatePatientDto } from './dto/create-patient.dto';
|
||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||
import { UpdatePatientDto } from './dto/update-patient.dto';
|
||||
import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PatientsService {
|
||||
@@ -89,36 +88,6 @@ export class PatientsService {
|
||||
return { success: true, data: patient };
|
||||
}
|
||||
|
||||
async findTreatments(patientId: string, organizationId: string, limit = 20) {
|
||||
await this.ensurePatient(patientId, organizationId);
|
||||
|
||||
const items = await this.prisma.patientTreatmentHistory.findMany({
|
||||
where: { patientId },
|
||||
orderBy: [{ treatmentAt: 'desc' }],
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return { success: true, data: items };
|
||||
}
|
||||
|
||||
async addTreatment(
|
||||
patientId: string,
|
||||
dto: CreateTreatmentHistoryDto,
|
||||
organizationId: string,
|
||||
) {
|
||||
await this.ensurePatient(patientId, organizationId);
|
||||
|
||||
const treatment = await this.prisma.patientTreatmentHistory.create({
|
||||
data: {
|
||||
...dto,
|
||||
treatmentAt: new Date(dto.treatmentAt),
|
||||
patientId,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: treatment };
|
||||
}
|
||||
|
||||
private async ensurePatient(id: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: { id, organizationId },
|
||||
|
||||
60
backend/src/modules/treatments/dto/treatment.dto.ts
Normal file
60
backend/src/modules/treatments/dto/treatment.dto.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
|
||||
|
||||
export class SaveTreatmentCaseDto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
clientId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
id?: string;
|
||||
|
||||
@IsIn(TREATMENT_TYPES)
|
||||
treatmentType: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
teeth: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
comment?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
attachmentIds?: string[];
|
||||
}
|
||||
|
||||
export class SaveTreatmentDraftDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SaveTreatmentCaseDto)
|
||||
cases: SaveTreatmentCaseDto[];
|
||||
}
|
||||
|
||||
export class SendTreatmentCaseDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID(undefined, { each: true })
|
||||
organizationIds: string[];
|
||||
}
|
||||
|
||||
export class ListPatientTreatmentHistoryDto {
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
16
backend/src/modules/treatments/treatment.utils.spec.ts
Normal file
16
backend/src/modules/treatments/treatment.utils.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { generateTreatmentTitle, normalizeTeeth } from './treatment.utils';
|
||||
|
||||
describe('treatment.utils', () => {
|
||||
it('normalizes valid FDI teeth', () => {
|
||||
expect(normalizeTeeth(['45', '14', '14', '99'])).toEqual(['14', '45']);
|
||||
});
|
||||
|
||||
it('generates a title from cases', () => {
|
||||
expect(
|
||||
generateTreatmentTitle([
|
||||
{ treatmentType: 'filling', teeth: ['14', '15'] },
|
||||
{ treatmentType: 'endo', teeth: ['45'] },
|
||||
]),
|
||||
).toBe('Filling 14, 15 · Endo 45');
|
||||
});
|
||||
});
|
||||
53
backend/src/modules/treatments/treatment.utils.ts
Normal file
53
backend/src/modules/treatments/treatment.utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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',
|
||||
'31', '32', '33', '34', '35', '36', '37', '38',
|
||||
'41', '42', '43', '44', '45', '46', '47', '48',
|
||||
]);
|
||||
|
||||
export function normalizeTeeth(teeth: unknown): string[] {
|
||||
if (!Array.isArray(teeth)) {
|
||||
return [];
|
||||
}
|
||||
const unique = new Set<string>();
|
||||
for (const tooth of teeth) {
|
||||
if (typeof tooth !== 'string') continue;
|
||||
const trimmed = tooth.trim();
|
||||
if (FDI_TOOTH_IDS.has(trimmed)) {
|
||||
unique.add(trimmed);
|
||||
}
|
||||
}
|
||||
return [...unique].sort();
|
||||
}
|
||||
|
||||
export function generateTreatmentTitle(
|
||||
cases: { treatmentType: string; teeth: string[] }[],
|
||||
): string {
|
||||
if (cases.length === 0) {
|
||||
return 'Treatment';
|
||||
}
|
||||
|
||||
const parts = cases.map((c) => {
|
||||
const label = c.treatmentType.charAt(0).toUpperCase() + c.treatmentType.slice(1);
|
||||
if (c.teeth.length > 0) {
|
||||
return `${label} ${c.teeth.join(', ')}`;
|
||||
}
|
||||
return label;
|
||||
});
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function mapTreatmentStatusForApi(status: TreatmentStatus): string {
|
||||
return status === TreatmentStatus.DRAFT ? 'draft' : 'completed';
|
||||
}
|
||||
148
backend/src/modules/treatments/treatments.controller.ts
Normal file
148
backend/src/modules/treatments/treatments.controller.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
|
||||
@ApiTags('treatments')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('treatments')
|
||||
export class TreatmentsController {
|
||||
constructor(private readonly treatmentsService: TreatmentsService) {}
|
||||
|
||||
@Get('linked-organizations')
|
||||
@ApiOperation({ summary: 'List active linked counterpart organizations (TAB_TREATMENT_READ)' })
|
||||
listLinkedOrganizations(@Req() req: { user: { id: string; organizationId?: string } }) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.listLinkedOrganizations(req.user.id, organizationId);
|
||||
}
|
||||
|
||||
@Get('patients/:patientId/history')
|
||||
@ApiOperation({ summary: 'List completed treatments for a patient (TAB_TREATMENT_READ)' })
|
||||
listPatientHistory(
|
||||
@Param('patientId') patientId: string,
|
||||
@Query('limit', new ParseIntPipe({ optional: true })) limit = 20,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.listPatientHistory(
|
||||
patientId,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('appointments/:appointmentId/draft')
|
||||
@ApiOperation({ summary: 'Get draft treatment for an appointment (TAB_TREATMENT_READ)' })
|
||||
getDraft(
|
||||
@Param('appointmentId') appointmentId: string,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.getDraftForAppointment(
|
||||
appointmentId,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('appointments/:appointmentId/draft')
|
||||
@ApiOperation({ summary: 'Save draft treatment for an appointment (TAB_TREATMENT_EDIT)' })
|
||||
saveDraft(
|
||||
@Param('appointmentId') appointmentId: string,
|
||||
@Body() dto: SaveTreatmentDraftDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.saveDraftForAppointment(
|
||||
appointmentId,
|
||||
dto,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('appointments/:appointmentId/cases/:caseClientKey/attachments')
|
||||
@ApiOperation({ summary: 'Upload attachments for a draft case (TAB_TREATMENT_EDIT)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
files: {
|
||||
type: 'array',
|
||||
items: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', 20, {
|
||||
storage: memoryStorage(),
|
||||
}),
|
||||
)
|
||||
uploadAttachments(
|
||||
@Param('appointmentId') appointmentId: string,
|
||||
@Param('caseClientKey') caseClientKey: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.uploadCaseAttachments(
|
||||
appointmentId,
|
||||
caseClientKey,
|
||||
files,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('attachments/:attachmentId/file')
|
||||
@ApiOperation({ summary: 'Download a treatment attachment (TAB_TREATMENT_READ)' })
|
||||
async downloadAttachment(
|
||||
@Param('attachmentId') attachmentId: string,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
const file = await this.treatmentsService.streamAttachmentFile(
|
||||
attachmentId,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', file.mimeType);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${file.fileName}"`);
|
||||
file.stream.pipe(res);
|
||||
}
|
||||
|
||||
@Post('cases/:caseId/send')
|
||||
@ApiOperation({ summary: 'Send a treatment case to linked organizations (TAB_TREATMENT_EDIT)' })
|
||||
sendCase(
|
||||
@Param('caseId') caseId: string,
|
||||
@Body() dto: SendTreatmentCaseDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.sendCase(caseId, dto, organizationId, req.user.id);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/treatments/treatments.module.ts
Normal file
10
backend/src/modules/treatments/treatments.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { TreatmentsController } from './treatments.controller';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TreatmentsController],
|
||||
providers: [TreatmentsService, PrismaService],
|
||||
})
|
||||
export class TreatmentsModule {}
|
||||
613
backend/src/modules/treatments/treatments.service.ts
Normal file
613
backend/src/modules/treatments/treatments.service.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LinkStatus, TreatmentStatus } from '@prisma/client';
|
||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto';
|
||||
import {
|
||||
generateTreatmentTitle,
|
||||
isTreatmentType,
|
||||
mapTreatmentStatusForApi,
|
||||
normalizeTeeth,
|
||||
} from './treatment.utils';
|
||||
|
||||
const treatmentInclude = {
|
||||
cases: {
|
||||
orderBy: [{ sortOrder: 'asc' as const }],
|
||||
include: {
|
||||
attachments: { orderBy: [{ createdAt: 'asc' as const }] },
|
||||
sends: { orderBy: [{ sentAt: 'asc' as const }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TreatmentsService {
|
||||
private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments');
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
|
||||
async listLinkedOrganizations(userId: string, organizationId: string) {
|
||||
await this.assertCanReadTreatment(userId, organizationId);
|
||||
|
||||
const [linksA, linksB] = await Promise.all([
|
||||
this.prisma.organizationLink.findMany({
|
||||
where: { organizationAId: organizationId, status: LinkStatus.ACTIVE },
|
||||
include: { organizationB: { select: { id: true, name: true } } },
|
||||
}),
|
||||
this.prisma.organizationLink.findMany({
|
||||
where: { organizationBId: organizationId, status: LinkStatus.ACTIVE },
|
||||
include: { organizationA: { select: { id: true, name: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = [
|
||||
...linksA.map((l) => ({
|
||||
id: l.organizationB.id,
|
||||
name: l.organizationB.name,
|
||||
active: true,
|
||||
})),
|
||||
...linksB.map((l) => ({
|
||||
id: l.organizationA.id,
|
||||
name: l.organizationA.name,
|
||||
active: true,
|
||||
})),
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
async listPatientHistory(
|
||||
patientId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
limit = 20,
|
||||
) {
|
||||
await this.assertCanReadTreatment(actorUserId, organizationId);
|
||||
await this.ensurePatientInOrg(patientId, organizationId);
|
||||
|
||||
const items = await this.prisma.treatment.findMany({
|
||||
where: {
|
||||
patientId,
|
||||
organizationId,
|
||||
status: TreatmentStatus.COMPLETED,
|
||||
},
|
||||
include: treatmentInclude,
|
||||
orderBy: [{ treatmentAt: 'desc' }],
|
||||
take: Math.min(Math.max(limit, 1), 100),
|
||||
});
|
||||
|
||||
return { success: true, data: items.map((t) => this.mapTreatment(t)) };
|
||||
}
|
||||
|
||||
async getDraftForAppointment(
|
||||
appointmentId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanReadTreatment(actorUserId, organizationId);
|
||||
const appointment = await this.ensureAppointmentProvider(
|
||||
appointmentId,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
false,
|
||||
);
|
||||
|
||||
const treatment = await this.prisma.treatment.findFirst({
|
||||
where: {
|
||||
appointmentId: appointment.id,
|
||||
organizationId,
|
||||
status: TreatmentStatus.DRAFT,
|
||||
},
|
||||
include: treatmentInclude,
|
||||
});
|
||||
|
||||
return { success: true, data: treatment ? this.mapTreatment(treatment) : null };
|
||||
}
|
||||
|
||||
async saveDraftForAppointment(
|
||||
appointmentId: string,
|
||||
dto: SaveTreatmentDraftDto,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
const appointment = await this.ensureAppointmentProvider(
|
||||
appointmentId,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
true,
|
||||
);
|
||||
|
||||
for (const c of dto.cases) {
|
||||
if (!isTreatmentType(c.treatmentType)) {
|
||||
throw new BadRequestException(`Invalid treatment type: ${c.treatmentType}`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCases = dto.cases.map((c, index) => ({
|
||||
...c,
|
||||
sortOrder: index,
|
||||
teeth: normalizeTeeth(c.teeth),
|
||||
comment: c.comment?.trim() || null,
|
||||
attachmentIds: c.attachmentIds ?? [],
|
||||
}));
|
||||
|
||||
const title = generateTreatmentTitle(
|
||||
normalizedCases.map((c) => ({ treatmentType: c.treatmentType, teeth: c.teeth })),
|
||||
);
|
||||
|
||||
const treatment = await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.treatment.findFirst({
|
||||
where: { appointmentId: appointment.id, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const saved = existing
|
||||
? await tx.treatment.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
title,
|
||||
treatmentAt: appointment.startAt,
|
||||
patientId: appointment.patientId,
|
||||
providerUserId: appointment.providerUserId,
|
||||
status: TreatmentStatus.DRAFT,
|
||||
},
|
||||
})
|
||||
: await tx.treatment.create({
|
||||
data: {
|
||||
organizationId,
|
||||
patientId: appointment.patientId,
|
||||
appointmentId: appointment.id,
|
||||
providerUserId: appointment.providerUserId,
|
||||
title,
|
||||
status: TreatmentStatus.DRAFT,
|
||||
treatmentAt: appointment.startAt,
|
||||
},
|
||||
});
|
||||
|
||||
const keepCaseIds = normalizedCases.map((c) => c.id).filter(Boolean) as string[];
|
||||
const existingCases = existing
|
||||
? await tx.treatmentCase.findMany({
|
||||
where: { treatmentId: saved.id },
|
||||
select: { id: true, sentAt: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const sentCaseIds = new Set(
|
||||
existingCases.filter((c) => c.sentAt).map((c) => c.id),
|
||||
);
|
||||
|
||||
const removableCaseIds = existingCases
|
||||
.filter((c) => !keepCaseIds.includes(c.id) && !c.sentAt)
|
||||
.map((c) => c.id);
|
||||
|
||||
if (removableCaseIds.length > 0) {
|
||||
await tx.treatmentCase.deleteMany({
|
||||
where: { id: { in: removableCaseIds }, treatmentId: saved.id },
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of normalizedCases) {
|
||||
if (c.id && sentCaseIds.has(c.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const row = c.id
|
||||
? await tx.treatmentCase.update({
|
||||
where: { id: c.id },
|
||||
data: {
|
||||
clientKey: c.clientId,
|
||||
sortOrder: c.sortOrder,
|
||||
treatmentType: c.treatmentType,
|
||||
teeth: c.teeth,
|
||||
comment: c.comment,
|
||||
},
|
||||
})
|
||||
: await tx.treatmentCase.create({
|
||||
data: {
|
||||
treatmentId: saved.id,
|
||||
clientKey: c.clientId,
|
||||
sortOrder: c.sortOrder,
|
||||
treatmentType: c.treatmentType,
|
||||
teeth: c.teeth,
|
||||
comment: c.comment,
|
||||
},
|
||||
});
|
||||
|
||||
const allowedAttachmentIds = new Set(c.attachmentIds);
|
||||
const pendingAttachments = await tx.treatmentCaseAttachment.findMany({
|
||||
where: {
|
||||
appointmentId: appointment.id,
|
||||
caseClientKey: c.clientId,
|
||||
},
|
||||
});
|
||||
|
||||
for (const attachment of pendingAttachments) {
|
||||
if (!allowedAttachmentIds.has(attachment.id)) {
|
||||
await tx.treatmentCaseAttachment.delete({ where: { id: attachment.id } });
|
||||
} else {
|
||||
await tx.treatmentCaseAttachment.update({
|
||||
where: { id: attachment.id },
|
||||
data: { caseId: row.id, appointmentId: null, caseClientKey: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.treatmentCaseAttachment.deleteMany({
|
||||
where: {
|
||||
caseId: row.id,
|
||||
id: { notIn: [...allowedAttachmentIds] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx.treatment.findUniqueOrThrow({
|
||||
where: { id: saved.id },
|
||||
include: treatmentInclude,
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTreatment(treatment) };
|
||||
}
|
||||
|
||||
async sendCase(
|
||||
caseId: string,
|
||||
dto: SendTreatmentCaseDto,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
|
||||
const treatmentCase = await this.prisma.treatmentCase.findFirst({
|
||||
where: {
|
||||
id: caseId,
|
||||
treatment: { organizationId },
|
||||
},
|
||||
include: {
|
||||
treatment: { select: { providerUserId: true, appointmentId: true } },
|
||||
sends: { select: { organizationId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!treatmentCase) {
|
||||
throw new NotFoundException('Treatment case not found');
|
||||
}
|
||||
|
||||
if (treatmentCase.treatment.providerUserId !== actorUserId) {
|
||||
const membership = await this.getMembership(actorUserId, organizationId);
|
||||
if (!membership?.isOwner) {
|
||||
throw new ForbiddenException('Only the appointment provider can send this case');
|
||||
}
|
||||
}
|
||||
|
||||
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
|
||||
const uniqueTargets = [...new Set(dto.organizationIds)];
|
||||
|
||||
for (const orgId of uniqueTargets) {
|
||||
if (!linkedOrgIds.has(orgId)) {
|
||||
throw new BadRequestException('One or more organizations are not active linked counterparts');
|
||||
}
|
||||
}
|
||||
|
||||
const alreadySent = new Set(treatmentCase.sends.map((s) => s.organizationId));
|
||||
const newTargets = uniqueTargets.filter((id) => !alreadySent.has(id));
|
||||
|
||||
if (newTargets.length === 0) {
|
||||
throw new BadRequestException('Case was already sent to all selected organizations');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.treatmentCaseSend.createMany({
|
||||
data: newTargets.map((organizationId) => ({
|
||||
caseId,
|
||||
organizationId,
|
||||
})),
|
||||
});
|
||||
|
||||
if (!treatmentCase.sentAt) {
|
||||
await tx.treatmentCase.update({
|
||||
where: { id: caseId },
|
||||
data: { sentAt: now },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const refreshed = await this.prisma.treatmentCase.findUniqueOrThrow({
|
||||
where: { id: caseId },
|
||||
include: {
|
||||
attachments: { orderBy: [{ createdAt: 'asc' }] },
|
||||
sends: { orderBy: [{ sentAt: 'asc' }] },
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapCase(refreshed) };
|
||||
}
|
||||
|
||||
async uploadCaseAttachments(
|
||||
appointmentId: string,
|
||||
caseClientKey: string,
|
||||
files: Express.Multer.File[],
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true);
|
||||
|
||||
if (!caseClientKey?.trim()) {
|
||||
throw new BadRequestException('caseClientKey is required');
|
||||
}
|
||||
|
||||
if (!files?.length) {
|
||||
throw new BadRequestException('At least one file is required');
|
||||
}
|
||||
|
||||
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.treatmentCaseAttachment.create({
|
||||
data: {
|
||||
appointmentId,
|
||||
caseClientKey,
|
||||
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,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanReadTreatment(actorUserId, organizationId);
|
||||
|
||||
const attachment = await this.prisma.treatmentCaseAttachment.findFirst({
|
||||
where: {
|
||||
id: attachmentId,
|
||||
OR: [
|
||||
{ case: { treatment: { organizationId } } },
|
||||
{ appointmentId: { not: null } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
case: { select: { treatment: { select: { organizationId: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
}
|
||||
|
||||
if (attachment.case && attachment.case.treatment.organizationId !== organizationId) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
}
|
||||
|
||||
if (!attachment.case && attachment.appointmentId) {
|
||||
const appointment = await this.prisma.appointment.findFirst({
|
||||
where: { id: attachment.appointmentId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!appointment) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(attachment.storagePath)) {
|
||||
throw new NotFoundException('File is no longer available');
|
||||
}
|
||||
|
||||
return {
|
||||
stream: createReadStream(attachment.storagePath),
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
private mapTreatment(treatment: {
|
||||
id: string;
|
||||
patientId: string;
|
||||
appointmentId: string | null;
|
||||
title: string;
|
||||
status: TreatmentStatus;
|
||||
treatmentAt: Date;
|
||||
cases: Array<{
|
||||
id: string;
|
||||
clientKey: string | null;
|
||||
treatmentType: string;
|
||||
teeth: unknown;
|
||||
comment: string | null;
|
||||
sentAt: Date | null;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}>;
|
||||
sends: Array<{ organizationId: string; sentAt: Date }>;
|
||||
}>;
|
||||
}) {
|
||||
const documents = treatment.cases.flatMap((c) =>
|
||||
c.attachments.map((a) => this.mapAttachment(a)),
|
||||
);
|
||||
|
||||
return {
|
||||
id: treatment.id,
|
||||
patientId: treatment.patientId,
|
||||
appointmentId: treatment.appointmentId,
|
||||
title: treatment.title,
|
||||
treatmentAt: treatment.treatmentAt.toISOString(),
|
||||
status: mapTreatmentStatusForApi(treatment.status),
|
||||
cases: treatment.cases.map((c) => this.mapCase(c)),
|
||||
documents,
|
||||
};
|
||||
}
|
||||
|
||||
private mapCase(c: {
|
||||
id: string;
|
||||
clientKey?: string | null;
|
||||
treatmentType: string;
|
||||
teeth: unknown;
|
||||
comment?: string | null;
|
||||
sentAt?: Date | null;
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}>;
|
||||
sends?: Array<{ organizationId: string; sentAt: Date }>;
|
||||
}) {
|
||||
return {
|
||||
id: c.id,
|
||||
clientId: c.clientKey ?? c.id,
|
||||
treatmentType: c.treatmentType,
|
||||
teeth: normalizeTeeth(c.teeth),
|
||||
notes: c.comment ?? null,
|
||||
sentAt: c.sentAt?.toISOString() ?? null,
|
||||
sendToOrganizationIds: c.sends?.map((s) => s.organizationId) ?? [],
|
||||
attachmentMetas: (c.attachments ?? []).map((a) => this.mapAttachment(a)),
|
||||
};
|
||||
}
|
||||
|
||||
private mapAttachment(a: {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}) {
|
||||
return {
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
sizeBytes: a.sizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
private async getActiveLinkedOrganizationIds(organizationId: string) {
|
||||
const [linksA, linksB] = await Promise.all([
|
||||
this.prisma.organizationLink.findMany({
|
||||
where: { organizationAId: organizationId, status: LinkStatus.ACTIVE },
|
||||
select: { organizationBId: true },
|
||||
}),
|
||||
this.prisma.organizationLink.findMany({
|
||||
where: { organizationBId: organizationId, status: LinkStatus.ACTIVE },
|
||||
select: { organizationAId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return new Set([
|
||||
...linksA.map((l) => l.organizationBId),
|
||||
...linksB.map((l) => l.organizationAId),
|
||||
]);
|
||||
}
|
||||
|
||||
private async ensurePatientInOrg(patientId: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: { id: patientId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient) {
|
||||
throw new NotFoundException('Patient not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAppointmentProvider(
|
||||
appointmentId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
requireProviderMatch: boolean,
|
||||
) {
|
||||
const appointment = await this.prisma.appointment.findFirst({
|
||||
where: { id: appointmentId, organizationId },
|
||||
select: {
|
||||
id: true,
|
||||
patientId: true,
|
||||
providerUserId: true,
|
||||
startAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException('Appointment not found');
|
||||
}
|
||||
|
||||
if (requireProviderMatch) {
|
||||
const membership = await this.getMembership(actorUserId, organizationId);
|
||||
const isOwner = membership?.isOwner ?? false;
|
||||
if (!isOwner && appointment.providerUserId !== actorUserId) {
|
||||
throw new ForbiddenException('You are not the provider for this appointment');
|
||||
}
|
||||
}
|
||||
|
||||
return appointment;
|
||||
}
|
||||
|
||||
private async assertCanReadTreatment(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_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to treatments');
|
||||
}
|
||||
|
||||
private async assertCanEditTreatment(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_TREATMENT_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You cannot edit treatments');
|
||||
}
|
||||
|
||||
private async getMembership(userId: string, organizationId: string) {
|
||||
return this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId, isActive: true },
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user