diff --git a/backend/.gitignore b/backend/.gitignore index 4b56acf..6233af0 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -45,6 +45,7 @@ lerna-debug.log* # temp directory .temp .tmp +/uploads # Runtime data pids diff --git a/backend/package-lock.json b/backend/package-lock.json index 7ce12a9..38d9f70 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,6 +35,7 @@ "express-formidable": "^1.2.0", "express-session": "^1.19.0", "helmet": "^8.1.0", + "multer": "^2.1.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", @@ -55,6 +56,7 @@ "@types/express": "^5.0.0", "@types/express-session": "^1.18.2", "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/pg": "^8.16.0", "@types/react": "^19.2.14", @@ -6251,6 +6253,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -12374,7 +12386,7 @@ }, "node_modules/multer": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "resolved": "https://registry.npmmirror.com/multer/-/multer-2.1.1.tgz", "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { diff --git a/backend/package.json b/backend/package.json index 9ecc4ad..fa41833 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,6 @@ "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@prisma/client": "^6.19.2", - "prisma": "^6.19.2", "adminjs": "^7.8.17", "axios": "^1.13.5", "bcrypt": "^6.0.0", @@ -54,10 +53,12 @@ "express-formidable": "^1.2.0", "express-session": "^1.19.0", "helmet": "^8.1.0", + "multer": "^2.1.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pg": "^8.18.0", + "prisma": "^6.19.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "styled-components": "^6.3.11", @@ -73,6 +74,7 @@ "@types/express": "^5.0.0", "@types/express-session": "^1.18.2", "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/pg": "^8.16.0", "@types/react": "^19.2.14", diff --git a/backend/prisma/migrations/20260519120000_add_treatments_feature/migration.sql b/backend/prisma/migrations/20260519120000_add_treatments_feature/migration.sql new file mode 100644 index 0000000..b3dd2e3 --- /dev/null +++ b/backend/prisma/migrations/20260519120000_add_treatments_feature/migration.sql @@ -0,0 +1,154 @@ +-- CreateEnum +CREATE TYPE "TreatmentStatus" AS ENUM ('DRAFT', 'COMPLETED'); + +-- CreateTable +CREATE TABLE "treatments" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "patientId" TEXT NOT NULL, + "appointmentId" TEXT, + "providerUserId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "status" "TreatmentStatus" NOT NULL DEFAULT 'DRAFT', + "treatmentAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "treatments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "treatment_cases" ( + "id" TEXT NOT NULL, + "treatmentId" TEXT NOT NULL, + "clientKey" TEXT, + "sortOrder" INTEGER NOT NULL, + "treatmentType" TEXT NOT NULL, + "teeth" JSONB NOT NULL, + "comment" TEXT, + "sentAt" TIMESTAMP(3), + + CONSTRAINT "treatment_cases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "treatment_case_attachments" ( + "id" TEXT NOT NULL, + "caseId" TEXT, + "appointmentId" TEXT, + "caseClientKey" TEXT, + "fileName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "storagePath" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "treatment_case_attachments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "treatment_case_sends" ( + "id" TEXT NOT NULL, + "caseId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "treatment_case_sends_pkey" PRIMARY KEY ("id") +); + +-- Migrate legacy patient_treatment_histories into treatments + single case per row +INSERT INTO "treatments" ( + "id", + "organizationId", + "patientId", + "appointmentId", + "providerUserId", + "title", + "status", + "treatmentAt", + "createdAt", + "updatedAt" +) +SELECT + h."id", + p."organizationId", + h."patientId", + NULL, + o."ownerId", + h."title", + 'COMPLETED'::"TreatmentStatus", + h."treatmentAt", + h."createdAt", + h."updatedAt" +FROM "patient_treatment_histories" h +JOIN "patients" p ON p."id" = h."patientId" +JOIN "organizations" o ON o."id" = p."organizationId"; + +INSERT INTO "treatment_cases" ( + "id", + "treatmentId", + "clientKey", + "sortOrder", + "treatmentType", + "teeth", + "comment", + "sentAt" +) +SELECT + h."id" || '-case', + h."id", + NULL, + 0, + 'visit', + CASE + WHEN h."tooth" IS NOT NULL AND btrim(h."tooth") <> '' THEN jsonb_build_array(h."tooth") + ELSE '[]'::jsonb + END, + h."notes", + NULL +FROM "patient_treatment_histories" h; + +-- Drop legacy table +DROP TABLE "patient_treatment_histories"; + +-- CreateIndex +CREATE UNIQUE INDEX "treatments_appointmentId_key" ON "treatments"("appointmentId"); + +-- CreateIndex +CREATE INDEX "treatments_patientId_treatmentAt_idx" ON "treatments"("patientId", "treatmentAt"); + +-- CreateIndex +CREATE INDEX "treatments_organizationId_status_idx" ON "treatments"("organizationId", "status"); + +-- CreateIndex +CREATE INDEX "treatment_cases_treatmentId_sortOrder_idx" ON "treatment_cases"("treatmentId", "sortOrder"); + +-- CreateIndex +CREATE INDEX "treatment_case_attachments_appointmentId_caseClientKey_idx" ON "treatment_case_attachments"("appointmentId", "caseClientKey"); + +-- CreateIndex +CREATE INDEX "treatment_case_attachments_caseId_idx" ON "treatment_case_attachments"("caseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "treatment_case_sends_caseId_organizationId_key" ON "treatment_case_sends"("caseId", "organizationId"); + +-- AddForeignKey +ALTER TABLE "treatments" ADD CONSTRAINT "treatments_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatments" ADD CONSTRAINT "treatments_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "patients"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatments" ADD CONSTRAINT "treatments_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "appointments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatment_cases" ADD CONSTRAINT "treatment_cases_treatmentId_fkey" FOREIGN KEY ("treatmentId") REFERENCES "treatments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatment_case_attachments" ADD CONSTRAINT "treatment_case_attachments_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "treatment_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatment_case_sends" ADD CONSTRAINT "treatment_case_sends_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "treatment_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatment_case_sends" ADD CONSTRAINT "treatment_case_sends_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7882769..8d3836b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -61,6 +61,8 @@ model Organization { sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter") patients Patient[] appointments Appointment[] + treatments Treatment[] + caseSends TreatmentCaseSend[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -81,8 +83,8 @@ model Patient { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id]) - treatments PatientTreatmentHistory[] + organization Organization @relation(fields: [organizationId], references: [id]) + treatments Treatment[] appointments Appointment[] @@index([organizationId, createdAt]) @@ -90,24 +92,6 @@ model Patient { @@map("patients") } -model PatientTreatmentHistory { - id String @id @default(uuid()) - patientId String - title String - status String - treatmentAt DateTime - tooth String? - notes String? - totalCost Float? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) - - @@index([patientId, treatmentAt]) - @@map("patient_treatment_histories") -} - model Appointment { id String @id @default(uuid()) organizationId String @@ -119,6 +103,7 @@ model Appointment { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + treatment Treatment? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -128,6 +113,84 @@ model Appointment { @@map("appointments") } +enum TreatmentStatus { + DRAFT + COMPLETED +} + +model Treatment { + id String @id @default(uuid()) + organizationId String + patientId String + appointmentId String? @unique + providerUserId String + title String + status TreatmentStatus @default(DRAFT) + treatmentAt DateTime + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull) + cases TreatmentCase[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([patientId, treatmentAt]) + @@index([organizationId, status]) + @@map("treatments") +} + +model TreatmentCase { + id String @id @default(uuid()) + treatmentId String + clientKey String? + sortOrder Int + treatmentType String + teeth Json + comment String? + sentAt DateTime? + + treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + attachments TreatmentCaseAttachment[] + sends TreatmentCaseSend[] + + @@index([treatmentId, sortOrder]) + @@map("treatment_cases") +} + +model TreatmentCaseAttachment { + id String @id @default(uuid()) + caseId String? + appointmentId String? + caseClientKey String? + fileName String + mimeType String + sizeBytes Int + storagePath String + + case TreatmentCase? @relation(fields: [caseId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@index([appointmentId, caseClientKey]) + @@index([caseId]) + @@map("treatment_case_attachments") +} + +model TreatmentCaseSend { + id String @id @default(uuid()) + caseId String + organizationId String + sentAt DateTime @default(now()) + + case TreatmentCase @relation(fields: [caseId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@unique([caseId, organizationId]) + @@map("treatment_case_sends") +} + model Plan { id String @id @default(uuid()) name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise" diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 270c45c..70c3e1c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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(), diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 2f9e48d..d5baa9f 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -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'); } diff --git a/backend/src/modules/patients/dto/create-treatment-history.dto.ts b/backend/src/modules/patients/dto/create-treatment-history.dto.ts deleted file mode 100644 index e0620d4..0000000 --- a/backend/src/modules/patients/dto/create-treatment-history.dto.ts +++ /dev/null @@ -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; -} diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index df77e81..0e97c6e 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -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); - } } diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index f3513f8..ac99936 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -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 }, diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts new file mode 100644 index 0000000..3d3c462 --- /dev/null +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -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; +} diff --git a/backend/src/modules/treatments/treatment.utils.spec.ts b/backend/src/modules/treatments/treatment.utils.spec.ts new file mode 100644 index 0000000..bee7dc8 --- /dev/null +++ b/backend/src/modules/treatments/treatment.utils.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/modules/treatments/treatment.utils.ts b/backend/src/modules/treatments/treatment.utils.ts new file mode 100644 index 0000000..fc13721 --- /dev/null +++ b/backend/src/modules/treatments/treatment.utils.ts @@ -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(); + 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'; +} diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts new file mode 100644 index 0000000..6f9bdac --- /dev/null +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/treatments/treatments.module.ts b/backend/src/modules/treatments/treatments.module.ts new file mode 100644 index 0000000..52fb3b6 --- /dev/null +++ b/backend/src/modules/treatments/treatments.module.ts @@ -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 {} diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts new file mode 100644 index 0000000..e44fb96 --- /dev/null +++ b/backend/src/modules/treatments/treatments.service.ts @@ -0,0 +1,625 @@ +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 }], + include: { organization: { select: { id: true, name: true } } }, + }, + }, + }, +}; + +@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' }], + include: { organization: { select: { id: true, name: true } } }, + }, + }, + }); + + 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; organization: { id: string; name: string } }>; + }>; + }) { + 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; organization?: { id: string; name: string } }>; + }) { + 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) ?? [], + sends: + c.sends?.map((s) => ({ + organizationId: s.organizationId, + organizationName: s.organization?.name ?? 'Unknown organization', + sentAt: s.sentAt.toISOString(), + })) ?? [], + 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 } } }, + }); + } +} diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index 9acc77f..744c193 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -8,16 +8,10 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { hasPermission } from '@/components/shared/permissions'; -import { - CreatePatientInput, - CreateTreatmentHistoryInput, - Patient, - TreatmentHistoryItem, -} from '@/types/patient'; +import { CreatePatientInput, Patient } from '@/types/patient'; import { PatientSearchSelect } from '../../../components/ui/patient/PatientSearchSelect'; import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal'; import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard'; -import { TreatmentHistoryPreview } from '../../../components/ui/patient/TreatmentHistoryPreview'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', @@ -32,12 +26,9 @@ export default function PatientsPage() { const [search, setSearch] = useState(''); const [patients, setPatients] = useState([]); const [selectedPatient, setSelectedPatient] = useState(); - const [treatments, setTreatments] = useState([]); const [loadingPatients, setLoadingPatients] = useState(false); - const [loadingTreatments, setLoadingTreatments] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false); const [savingPatient, setSavingPatient] = useState(false); - const [savingTreatment, setSavingTreatment] = useState(false); const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); @@ -79,19 +70,6 @@ export default function PatientsPage() { } } - async function loadTreatments(patientId: string) { - setLoadingTreatments(true); - toast.setError(''); - try { - const response = await patientsApi.listTreatments(patientId); - setTreatments(response.data); - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, 'Failed to load treatment history.')); - } finally { - setLoadingTreatments(false); - } - } - async function handleCreatePatient() { setSavingPatient(true); toast.setError(''); @@ -101,7 +79,6 @@ export default function PatientsPage() { setPatientForm(EMPTY_PATIENT_FORM); await loadPatients(search); setSelectedPatient(response.data); - await loadTreatments(response.data.id); toast.showSuccess( `Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`, ); @@ -112,31 +89,6 @@ export default function PatientsPage() { } } - async function handleQuickAddTreatment() { - if (!selectedPatient) { - return; - } - - const payload: CreateTreatmentHistoryInput = { - title: 'Initial consultation', - status: 'scheduled', - treatmentAt: new Date().toISOString(), - notes: 'Created from quick action on patients page.', - }; - - setSavingTreatment(true); - toast.setError(''); - try { - await patientsApi.addTreatment(selectedPatient.id, payload); - await loadTreatments(selectedPatient.id); - toast.showSuccess('Treatment entry added successfully.'); - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, 'Failed to add treatment entry.')); - } finally { - setSavingTreatment(false); - } - } - return (
@@ -179,31 +131,13 @@ export default function PatientsPage() { onSearchChange={setSearch} patients={sortedPatients} selectedPatientId={selectedPatient?.id} - onSelectPatient={(patient) => { - setSelectedPatient(patient); - void loadTreatments(patient.id); - }} + onSelectPatient={setSelectedPatient} loading={loadingPatients} />
-
- -
-
diff --git a/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index 2d1733c..210bf25 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -82,7 +82,8 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean return ( hasPermission(org, 'TAB_APPOINTMENTS_READ') || hasPermission(org, 'TAB_APPOINTMENTS_EDIT') || - hasPermission(org, 'TAB_TREATMENT_EDIT') + hasPermission(org, 'TAB_TREATMENT_EDIT') || + hasPermission(org, 'TAB_TREATMENT_READ') ); } @@ -92,3 +93,13 @@ export function canEditTreatment(org: Organization | null): boolean { if (org.isOwner) return true; return hasPermission(org, 'TAB_TREATMENT_EDIT'); } + +/** View treatment workspace (read-only or edit) */ +export function canViewTreatment(org: Organization | null): boolean { + if (!org) return false; + if (org.isOwner) return true; + return ( + hasPermission(org, 'TAB_TREATMENT_READ') || + hasPermission(org, 'TAB_TREATMENT_EDIT') + ); +} diff --git a/frontend/src/components/treatment/caseSendLabel.ts b/frontend/src/components/treatment/caseSendLabel.ts new file mode 100644 index 0000000..029dc3e --- /dev/null +++ b/frontend/src/components/treatment/caseSendLabel.ts @@ -0,0 +1,40 @@ +import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment'; + +export function formatCaseSentLines( + sends: TreatmentCaseSendInfo[] | undefined, + fallback?: { + organizationIds: string[]; + sentAt: string | null; + orgs?: LinkedOrganizationOption[]; + }, +): string[] { + if (sends?.length) { + return sends.map((s) => { + const at = new Date(s.sentAt).toLocaleString(); + return `Sent to ${s.organizationName} at ${at}`; + }); + } + + if (fallback?.sentAt && fallback.organizationIds.length > 0) { + const at = new Date(fallback.sentAt).toLocaleString(); + const nameById = new Map(fallback.orgs?.map((o) => [o.id, o.name]) ?? []); + return fallback.organizationIds.map((id) => { + const name = nameById.get(id) ?? 'organization'; + return `Sent to ${name} at ${at}`; + }); + } + + return []; +} + +export function formatCaseSentSummary( + sends: TreatmentCaseSendInfo[] | undefined, + fallback?: { + organizationIds: string[]; + sentAt: string | null; + orgs?: LinkedOrganizationOption[]; + }, +): string | null { + const lines = formatCaseSentLines(sends, fallback); + return lines.length > 0 ? lines.join(' · ') : null; +} diff --git a/frontend/src/components/ui/patient/TreatmentHistoryPreview.tsx b/frontend/src/components/ui/patient/TreatmentHistoryPreview.tsx deleted file mode 100644 index 9eb80a2..0000000 --- a/frontend/src/components/ui/patient/TreatmentHistoryPreview.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { TreatmentHistoryItem } from '@/types/patient'; - -interface TreatmentHistoryPreviewProps { - items: TreatmentHistoryItem[]; - loading?: boolean; -} - -export function TreatmentHistoryPreview({ items, loading = false }: TreatmentHistoryPreviewProps) { - return ( -
-

Treatment History

- - {loading &&

Loading treatment history...

} - - {!loading && items.length === 0 && ( -

No treatment history yet.

- )} - -
- {items.map((item) => ( -
-
-

{item.title}

-

- {new Date(item.treatmentAt).toLocaleDateString()} -

-
-

- Status: {item.status} - {item.tooth ? ` | Tooth: ${item.tooth}` : ''} -

-
- ))} -
-
- ); -} diff --git a/frontend/src/components/ui/treatment/CaseLatestAttachmentPreview.tsx b/frontend/src/components/ui/treatment/CaseLatestAttachmentPreview.tsx new file mode 100644 index 0000000..c9f363d --- /dev/null +++ b/frontend/src/components/ui/treatment/CaseLatestAttachmentPreview.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { FileText } from 'lucide-react'; +import { treatmentsApi } from '@/lib/api/treatments'; +import type { TreatmentAttachmentMeta } from '@/types/treatment'; + +interface CaseLatestAttachmentPreviewProps { + attachment: TreatmentAttachmentMeta; + className?: string; +} + +export function CaseLatestAttachmentPreview({ + attachment, + className = 'aspect-square w-full max-w-[11rem]', +}: CaseLatestAttachmentPreviewProps) { + const [url, setUrl] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + let objectUrl: string | null = null; + + void (async () => { + try { + const blob = await treatmentsApi.getAttachmentFileBlob(attachment.id); + if (cancelled) return; + objectUrl = URL.createObjectURL(blob); + setUrl(objectUrl); + setFailed(false); + } catch { + if (!cancelled) setFailed(true); + } + })(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [attachment.id]); + + const isImage = attachment.mimeType.startsWith('image/'); + const isPdf = attachment.mimeType === 'application/pdf'; + + return ( +
+ {url && isImage ? ( + {attachment.fileName} + ) : url && isPdf ? ( +