feature: a minimal v1 backend implemented for treatments feature.

This commit is contained in:
2026-05-19 22:29:29 +03:30
parent 935bb8c214
commit f743046b38
27 changed files with 1636 additions and 656 deletions

1
backend/.gitignore vendored
View File

@@ -45,6 +45,7 @@ lerna-debug.log*
# temp directory
.temp
.tmp
/uploads
# Runtime data
pids

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

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

View 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;
}

View 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');
});
});

View 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';
}

View 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);
}
}

View 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 {}

View 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 } } },
});
}
}