diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2eb6faed..1fcd0c03 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,8 +14,12 @@ See `PRD.md` for product scope and `ARCHITECTURE_ESSENTIALS.md` for the quick-re 1. **Modular monolith first, not microservices.** One deployable backend, internally organized into bounded modules (auth, doctors, appointments, referrals, consultations, orders, facilities, notifications). Split into services only when a concrete scaling or team-ownership problem demands it — not preemptively (see PRD §8.3). 2. **Orders are append-only.** Prescriptions and lab orders are never mutated after issuance. Corrections create a new versioned record referencing the original. This is a hard architectural constraint, not a style preference (clinical/legal safety). 3. **The referral carries state, not a pointer to state.** A referral snapshots the intake + consultation notes it's built on, so later edits elsewhere can't silently rewrite what a specialist already saw. -4. **Boring tech, correct concurrency.** Availability booking is a correctness problem before it's a scaling problem — solve double-booking with real DB constraints/transactions, not application-level checks. -5. **Build vs. buy for anything not core IP.** Video/chat infra, SMS/email delivery, file storage — use managed providers behind an internal interface so they're swappable. +4. **Clinical records are immutable once finalized.** Prescriptions and lab + orders are append-only from issuance (§1.2); a consultation note is editable + as a draft but immutable from sign-off, with corrections as addenda (§4.3, + issue #64). Enforced at the DB level, not just in application code. +5. **Boring tech, correct concurrency.** Availability booking is a correctness problem before it's a scaling problem — solve double-booking with real DB constraints/transactions, not application-level checks. +6. **Build vs. buy for anything not core IP.** Video/chat infra, SMS/email delivery, file storage — use managed providers behind an internal interface so they're swappable. ## 2. Tech stack diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index e2c0213a..bca9d5d2 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -18,6 +18,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc 4. **Cross-module access only through a module's service interface.** Never import another module's `repositories/*` directly. 5. **Facilities write to their own `FulfillmentRecord` only** — never directly to `Prescription.status` / `LabOrder.status`. 6. Every module = `routes/ controllers/ services/ repositories/`. Keep that shape. +7. **Never edit a `Consultation` note/outcome once it's signed off.** Corrections = `ConsultationAddendum` rows. Enforced by a DB trigger on the `Consultation` table (`notes`/`outcome`/`signedAt`), not only in the repository — a direct `UPDATE` must fail (issue #64). ## Data model map (see ARCHITECTURE.md §4 for full field lists) `User` (including `locale`, default `en`, Phase 9 #133) → `PatientProfile | DoctorProfile | FacilityProfile`; `User` → `AdminInvite` (Phase 8, issue #39 — hashed/expiring/single-use; `User.deactivatedAt` soft-deactivates an account, refusing login/refresh/access tokens without deleting anything) diff --git a/backend/prisma/migrations/20261015182926_consultation_note_sign_off/migration.sql b/backend/prisma/migrations/20261015182926_consultation_note_sign_off/migration.sql new file mode 100644 index 00000000..70088b01 --- /dev/null +++ b/backend/prisma/migrations/20261015182926_consultation_note_sign_off/migration.sql @@ -0,0 +1,65 @@ +-- AlterTable +ALTER TABLE "Consultation" ADD COLUMN "doctorProfileId" TEXT NOT NULL, +ADD COLUMN "signedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "ConsultationAddendum" ( + "id" TEXT NOT NULL, + "consultationId" TEXT NOT NULL, + "authorDoctorProfileId" TEXT NOT NULL, + "body" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ConsultationAddendum_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ConsultationAddendum_consultationId_idx" ON "ConsultationAddendum"("consultationId"); + +-- CreateIndex +CREATE INDEX "Consultation_doctorProfileId_idx" ON "Consultation"("doctorProfileId"); + +-- CreateIndex +CREATE INDEX "Consultation_signedAt_idx" ON "Consultation"("signedAt"); + +-- AddForeignKey +ALTER TABLE "Consultation" ADD CONSTRAINT "Consultation_doctorProfileId_fkey" FOREIGN KEY ("doctorProfileId") REFERENCES "DoctorProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ConsultationAddendum" ADD CONSTRAINT "ConsultationAddendum_consultationId_fkey" FOREIGN KEY ("consultationId") REFERENCES "Consultation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- --------------------------------------------------------------------------- +-- Consultation sign-off immutability (issue #64). +-- +-- Application code already refuses to write a signed note (see +-- consultations/repositories: updates are scoped to `signedAt IS NULL`), but +-- that only binds callers who go through it. This trigger is the guarantee: +-- once `signedAt` is set, `notes`, `outcome` and `signedAt` itself cannot +-- change — including a direct `UPDATE` from Prisma, a psql session, or any +-- future module that forgets the rule. Corrections go in +-- `ConsultationAddendum`, which is append-only by construction. +-- +-- Modelled on the same reasoning as hard rule 1 (never mutate an issued +-- Prescription/LabOrder) — clinical notes are legal records. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "prevent_signed_consultation_mutation"() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD."signedAt" IS NOT NULL AND ( + NEW."notes" IS DISTINCT FROM OLD."notes" + OR NEW."outcome" IS DISTINCT FROM OLD."outcome" + OR NEW."signedAt" IS DISTINCT FROM OLD."signedAt" + ) THEN + RAISE EXCEPTION 'Consultation % is signed off and immutable — add a ConsultationAddendum instead of editing it', OLD."id" + USING ERRCODE = 'raise_exception'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "Consultation_signed_immutable" + BEFORE UPDATE ON "Consultation" + FOR EACH ROW + EXECUTE FUNCTION "prevent_signed_consultation_mutation"(); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5856a3b9..564256b2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -726,8 +726,13 @@ model IntakeForm { // record of a doctor having seen the patient at a specific appointment. // `appointmentId` is the lineage seam — Referral.parentReferralId is // derived through it (consultation → appointment → referral), which is -// what loop detection walks. Full consultation-note workflow (append-only -// addenda, sign-off) stays issue #4/#64's scope. +// what loop detection walks. +// +// Clinical notes are legal records (issue #64): once signed off, the note +// and outcome are immutable. Corrections are *addenda*, never edits — the +// same reason Prescription/LabOrder are append-only (hard rule 1). +// Immutability is enforced twice: the repository only writes drafts, and a +// BEFORE UPDATE trigger in the sign-off migration rejects anything else. model Consultation { id String @id @default(uuid()) // unique = "a consultation is closed once per appointment", enforced by the @@ -751,6 +756,25 @@ model Consultation { // Phase 3: the referrals that grew out of this consultation (the // opposite side of Referral.sourceConsultationId). referrals Referral[] + + @@index([doctorProfileId]) + // Auto sign-off scans for drafts past their edit window (see + // CONSULTATION_AUTO_SIGNOFF_HOURS). + @@index([signedAt]) +} + +model ConsultationAddendum { + id String @id @default(uuid()) + consultationId String + consultation Consultation @relation(fields: [consultationId], references: [id], onDelete: Cascade) + + // Only the authoring doctor may add an addendum (enforced in the service, + // not just the controller). + authorDoctorProfileId String + body String + createdAt DateTime @default(now()) + + @@index([consultationId]) } // Phase 3 (issues #5/#65/#66/#67/#68). Snapshot rule (FR-7/FR-8, hard diff --git a/backend/src/jobs/consultation-auto-sign-off.ts b/backend/src/jobs/consultation-auto-sign-off.ts new file mode 100644 index 00000000..e5d908fc --- /dev/null +++ b/backend/src/jobs/consultation-auto-sign-off.ts @@ -0,0 +1,37 @@ +// Auto sign-off of stale consultation drafts (issue #64). +// +// A plain interval, deliberately — ARCHITECTURE.md §6.3.2 / §2 flag a real +// queue (BullMQ/Redis) as premature until a job actually needs one. This +// job is a periodic `UPDATE ... WHERE signedAt IS NULL AND createdAt <= +// cutoff`, which an interval handles fine. +// +// Started from src/server.ts (not from app.ts, so importing the app in a +// test never starts a timer). +import { config } from '../config'; +import { autoSignOffDueConsultations } from '../modules/consultations/services'; + +/** Runs one pass. Called by the interval, and directly by tests. */ +export async function runConsultationAutoSignOff(now: Date = new Date()): Promise { + const signedOff = await autoSignOffDueConsultations(now); + if (signedOff > 0) { + // eslint-disable-next-line no-console + console.log(`[jobs] auto-signed off ${signedOff} consultation(s) past the edit window`); + } + return signedOff; +} + +export function startConsultationAutoSignOffJob(): NodeJS.Timeout { + const intervalMs = config.consultations.autoSignoffCheckIntervalMinutes * 60_000; + + const timer = setInterval(() => { + // Never let a job failure take the process down with it. + void runConsultationAutoSignOff().catch((error: unknown) => { + // eslint-disable-next-line no-console + console.error('[jobs] consultation auto sign-off failed', error); + }); + }, intervalMs); + + // Don't keep the process alive just for this timer. + timer.unref(); + return timer; +} diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 7b788880..cd4e3b4b 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -150,7 +150,12 @@ export async function getDoctorAppointmentContext(userId: string, appointmentId: if (!appointment) throw new HttpError(404, 'Appointment not found.'); if (appointment.doctorProfile.userId !== userId) throw new HttpError(403, 'You can only issue orders for your own appointments.'); if (appointment.doctorProfile.verificationStatus !== 'verified') throw new HttpError(403, 'A verified doctor profile is required to issue orders.'); - return { id: appointment.id, patientProfileId: appointment.patientProfileId, doctorProfileId: appointment.doctorProfileId }; + return { + id: appointment.id, + patientProfileId: appointment.patientProfileId, + doctorProfileId: appointment.doctorProfileId, + status: appointment.status, + }; } /** diff --git a/backend/src/modules/consultations/controllers/index.ts b/backend/src/modules/consultations/controllers/index.ts index 3588f15f..09c2ebb2 100644 --- a/backend/src/modules/consultations/controllers/index.ts +++ b/backend/src/modules/consultations/controllers/index.ts @@ -4,6 +4,13 @@ import { closeConsultationSchema, createConsultationSchema, createReferralsSchem import { HttpError } from '../../../http-error'; import { auditActor } from '../../../middleware/request-id'; import { getParam } from '../../../request-params'; +import * as consultationsService from '../services'; +import { + addAddendumSchema, + createConsultationSchema, + createReferralsSchema, + updateConsultationSchema, +} from '../validation'; /** POST /appointments/:appointmentId/consultation — close (create) the * consultation for the appointment in the path. */ @@ -25,22 +32,43 @@ export async function closeConsultation(req: Request, res: Response) { * Kept because Phase 3's referral flow and its tests address it this way. */ export async function create(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); + return req.user.id; +} + +export async function create(req: Request, res: Response) { const input = createConsultationSchema.parse(req.body); const { consultation } = await consultationsService.createConsultation(auditActor(req), input); res.status(201).json(consultation); } -export async function getOne(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const consultation = await consultationsService.getConsultation(req.user.id, getParam(req, 'consultationId')); +export async function get(req: Request, res: Response) { + const consultation = await consultationsService.getConsultation(requireUserId(req), getParam(req, 'id')); + res.status(200).json(consultation); +} + +export async function update(req: Request, res: Response) { + const input = updateConsultationSchema.parse(req.body); + const consultation = await consultationsService.updateConsultation(requireUserId(req), getParam(req, 'id'), input); res.status(200).json(consultation); } +export async function signOff(req: Request, res: Response) { + const consultation = await consultationsService.signOffConsultation(requireUserId(req), getParam(req, 'id')); + res.status(200).json(consultation); +} + +export async function addAddendum(req: Request, res: Response) { + const { body } = addAddendumSchema.parse(req.body); + const consultation = await consultationsService.addAddendum(requireUserId(req), getParam(req, 'id'), body); + res.status(201).json(consultation); +} + +/** Issue #66 (PRD §8.2.4): multiple simultaneous referrals from one + * consultation, atomic all-or-none, each with its own snapshot copy. */ export async function createReferrals(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); const { targets } = createReferralsSchema.parse(req.body); const referrals = await consultationsService.createReferrals( - req.user.id, + requireUserId(req), getParam(req, 'consultationId'), targets, ); diff --git a/backend/src/modules/consultations/repositories/index.ts b/backend/src/modules/consultations/repositories/index.ts index a92d9b53..c88b2b75 100644 --- a/backend/src/modules/consultations/repositories/index.ts +++ b/backend/src/modules/consultations/repositories/index.ts @@ -1,7 +1,22 @@ +// consultations repositories — data access only. Not to be imported by +// other modules directly — go through services/ instead (AGENTS.md). +// +// Every write that could touch a signed-off note is scoped with +// `signedAt: null` in the WHERE clause: the update either matches a draft +// (count === 1) or it matches nothing. That's the repository half of the +// immutability rule; the DB trigger added in the migration is the half that +// holds even for callers who skip this file. import { prisma } from '../../../db/prisma-client'; import type { Consultation, ConsultationOutcome, Prisma } from '@prisma/client'; -type DbClient = typeof prisma | Prisma.TransactionClient; +export function createConsultation(data: { + appointmentId: string; + doctorProfileId: string; + notes?: string; + outcome?: ConsultationOutcome; +}) { + return prisma.consultation.create({ data }); +} export function createConsultation( db: DbClient, diff --git a/backend/src/modules/consultations/routes/index.ts b/backend/src/modules/consultations/routes/index.ts index d2227f43..76c9e6d8 100644 --- a/backend/src/modules/consultations/routes/index.ts +++ b/backend/src/modules/consultations/routes/index.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import { authenticate, requireRole } from '../../../middleware/auth'; import * as consultationsController from '../controllers'; /** @@ -42,6 +42,5 @@ consultationsRouter.post( '/:consultationId/referrals', authenticate, requireRole('doctor'), - requireVerifiedEmail, consultationsController.createReferrals, ); diff --git a/backend/src/modules/consultations/services/index.ts b/backend/src/modules/consultations/services/index.ts index cee753f5..df12d8cc 100644 --- a/backend/src/modules/consultations/services/index.ts +++ b/backend/src/modules/consultations/services/index.ts @@ -18,6 +18,63 @@ import * as patientsService from '../../patients/services'; import * as consultationsRepo from '../repositories'; import * as referralsService from '../../referrals/services'; import type { ReferralTargetInput } from '../../referrals/services'; +import * as consultationsRepo from '../repositories'; +import type { ConsultationOutcome } from '@prisma/client'; + +export interface ConsultationReadModel { + id: string; + doctorProfileId: string; + notes: string | null; + outcome: ConsultationOutcome | null; + signedAt: Date | null; + createdAt: Date; + /** false once signed off — clients should hide the note editor. */ + editable: boolean; + addenda: { id: string; body: string; authorDoctorProfileId: string; createdAt: Date }[]; +} + +function toReadModel(consultation: { + id: string; + doctorProfileId: string; + notes: string | null; + outcome: ConsultationOutcome | null; + signedAt: Date | null; + createdAt: Date; + addenda: { id: string; body: string; authorDoctorProfileId: string; createdAt: Date }[]; +}): ConsultationReadModel { + return { + id: consultation.id, + doctorProfileId: consultation.doctorProfileId, + notes: consultation.notes, + outcome: consultation.outcome, + signedAt: consultation.signedAt, + createdAt: consultation.createdAt, + // Signed-off notes are immutable, but addenda are still allowed — the + // note body itself is what stops being editable. + editable: consultation.signedAt === null, + addenda: consultation.addenda, + }; +} + +/** The caller's own doctor profile (cross-module access through the doctors + * module's service interface, never its repository). */ +async function requireAuthoringDoctor(userId: string) { + return doctorsService.getMyDoctorProfile(userId); +} + +async function requireOwnConsultation(userId: string, consultationId: string) { + const consultation = await consultationsRepo.findConsultationById(consultationId); + if (!consultation) throw new HttpError(404, 'Consultation not found.'); + + const doctorProfile = await requireAuthoringDoctor(userId); + // Same 404 whether it doesn't exist or belongs to another doctor — a + // consultation id shouldn't confirm whose patient thread it is. + if (consultation.doctorProfileId !== doctorProfile.id) { + throw new HttpError(404, 'Consultation not found.'); + } + + return consultation; +} export interface ConsultationInput { appointmentId: string; @@ -144,13 +201,13 @@ export async function getConsultation(userId: string, consultationId: string) { return { ...consultation, notes: decryptField(consultation.notes) }; } } - throw new HttpError(403, 'You do not have access to this consultation.'); + return signedOff; } /** * POST /consultations/:id/referrals (issue #66): multiple simultaneous - * referrals from one consultation, atomically. Delegates to the - * referrals service — the referral write surface lives there; this is a + * referrals from one consultation, atomically. Delegates to the referrals + * service — the referral write surface lives there; this is a * service-to-service call across the module boundary, never a repository * import. */ diff --git a/backend/src/modules/consultations/validation.ts b/backend/src/modules/consultations/validation.ts index b3f71f14..5fd6ce5d 100644 --- a/backend/src/modules/consultations/validation.ts +++ b/backend/src/modules/consultations/validation.ts @@ -10,11 +10,29 @@ export const closeConsultationSchema = z.object({ /** Create route (Phase 3's slice): the same row, addressable before sign-off. */ export const createConsultationSchema = z.object({ + // One consultation per appointment (unique in the DB) — the appointment + // is the record this note closes. appointmentId: z.string().min(1), - outcome: z.enum(['resolved', 'referred', 'follow_up_needed']).optional(), notes: z.string().trim().min(1).optional(), + outcome: outcomeSchema.optional(), }); +/** PATCH is a partial update, but an empty body is meaningless — require at + * least one field so a no-op request is a 400 rather than silent success. */ +export const updateConsultationSchema = z + .object({ + notes: z.string().trim().min(1).optional(), + outcome: outcomeSchema.optional(), + }) + .refine((value) => value.notes !== undefined || value.outcome !== undefined, { + message: 'Provide notes and/or outcome.', + }); + +export const addAddendumSchema = z.object({ + body: z.string().trim().min(1), +}); + +// Issue #66: several referrals from one consultation, all-or-none. export const createReferralsSchema = z.object({ targets: z.array(referralTargetSchema).min(1), }); diff --git a/backend/src/server.ts b/backend/src/server.ts index eb35a784..3972683f 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -4,6 +4,7 @@ import { pruneLoginAttempts } from './modules/auth/services'; import { pruneOldAccessLogs } from './modules/audit/services'; import { startPatientExportJobs } from './jobs/patient-exports'; import { startEscrowEventPoller } from './jobs/escrow-events'; +import { startConsultationAutoSignOffJob } from './jobs/consultation-auto-sign-off'; import { validatePlatformSigners } from './modules/payments/services/platform-signers'; import { validateSep10Config, validateWalletLinkConfig, verifyPaymentsNetwork } from './modules/payments/services'; import { startAccountPurgeJob } from './jobs/account-purge'; diff --git a/backend/tests/integration/consultations.test.mts b/backend/tests/integration/consultations.test.mts new file mode 100644 index 00000000..407b89b0 --- /dev/null +++ b/backend/tests/integration/consultations.test.mts @@ -0,0 +1,278 @@ +// Consultation sign-off + append-only addenda (Phase 2 #64) — integration, +// against a real Postgres, because the point of this issue is a DB-level +// guarantee: an UPDATE on a signed-off note must fail even when it bypasses +// the repository entirely. A mock can't demonstrate that. +// +// Self-contained like the other integration suites: throwaway Postgres on +// port 5448 (one port per file — vitest runs files in parallel). +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5448; +const runId = Date.now(); + +// Set before importing anything from src/ — config/index.ts reads process.env +// at module load, and a 1-hour window keeps the auto-sign-off test deterministic. +process.env.DATABASE_URL = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; +process.env.CONSULTATION_AUTO_SIGNOFF_HOURS = '1'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-consultations-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; + +describe('consultation sign-off', () => { + let app: App; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let autoSignOffDueConsultations: typeof import('../../src/modules/consultations/services/index.js').autoSignOffDueConsultations; + + let authorToken: string; + let otherDoctorToken: string; + let consultationId: string; + // A consultation closes exactly one appointment (unique FK), so each test + // that creates a note gets its own seeded appointment. + let appointmentIds: string[] = []; + + function create(token: string, body: Record = {}) { + const appointmentId = appointmentIds.shift(); + if (!appointmentId) throw new Error('no seeded appointment left for this test'); + return request(app) + .post('/consultations') + .set('Authorization', `Bearer ${token}`) + .send({ appointmentId, ...body }); + } + + function get(token: string, id = consultationId) { + return request(app).get(`/consultations/${id}`).set('Authorization', `Bearer ${token}`); + } + + function patch(token: string, body: Record, id = consultationId) { + return request(app).patch(`/consultations/${id}`).set('Authorization', `Bearer ${token}`).send(body); + } + + function signOff(token: string, id = consultationId) { + return request(app).post(`/consultations/${id}/sign-off`).set('Authorization', `Bearer ${token}`); + } + + function addAddendum(token: string, body: string, id = consultationId) { + return request(app).post(`/consultations/${id}/addenda`).set('Authorization', `Bearer ${token}`).send({ body }); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + const [appModule, dbModule, consultationsModule] = await Promise.all([ + import('../../src/app.js'), + import('../../src/db/prisma-client.js'), + import('../../src/modules/consultations/services/index.js'), + ]); + app = appModule.createApp(); + prisma = dbModule.prisma; + autoSignOffDueConsultations = consultationsModule.autoSignOffDueConsultations; + + const author = await request(app) + .post('/auth/signup') + .send({ role: 'doctor', email: `consult-author-${runId}@example.com`, password: 'doctor-pass-1', fullName: 'Author Doctor' }); + expect(author.status).toBe(200); + authorToken = author.body.accessToken as string; + + const other = await request(app) + .post('/auth/signup') + .send({ role: 'doctor', email: `consult-other-${runId}@example.com`, password: 'doctor-pass-2', fullName: 'Other Doctor' }); + expect(other.status).toBe(200); + otherDoctorToken = other.body.accessToken as string; + + // A consultation is written against a doctor-owned appointment, and the + // appointments service requires a verified doctor, so verify the author + // and seed a patient + three booked appointments directly. + const patient = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email: `consult-patient-${runId}@example.com`, password: 'patient-pass-1', fullName: 'Consult Patient' }); + expect(patient.status).toBe(200); + + const patientProfile = await prisma.patientProfile.findUniqueOrThrow({ + where: { userId: (await prisma.user.findUniqueOrThrow({ where: { email: `consult-patient-${runId}@example.com` } })).id }, + }); + const authorUser = await prisma.user.findUniqueOrThrow({ where: { email: `consult-author-${runId}@example.com` } }); + const authorProfile = await prisma.doctorProfile.update({ + where: { userId: authorUser.id }, + data: { verificationStatus: 'verified' }, + }); + + for (let index = 0; index < 3; index += 1) { + const startTime = new Date(Date.now() + (index + 1) * 60 * 60 * 1000); + const slot = await prisma.availabilitySlot.create({ + data: { + doctorProfileId: authorProfile.id, + status: 'booked', + startTime, + endTime: new Date(startTime.getTime() + 30 * 60 * 1000), + }, + }); + const appointment = await prisma.appointment.create({ + data: { + patientProfileId: patientProfile.id, + doctorProfileId: authorProfile.id, + availabilitySlotId: slot.id, + // A consultation is recorded once the visit is over. + status: 'completed', + }, + }); + appointmentIds.push(appointment.id); + } + + const created = await create(authorToken, { notes: 'Initial assessment', outcome: 'follow_up_needed' }); + expect(created.status).toBe(201); + consultationId = created.body.id as string; + expect(created.body.signedAt).toBeNull(); + expect(created.body.editable).toBe(true); + expect(created.body.addenda).toEqual([]); + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('lets the authoring doctor edit the note while it is a draft', async () => { + const updated = await patch(authorToken, { notes: 'Revised assessment' }); + expect(updated.status).toBe(200); + expect(updated.body.notes).toBe('Revised assessment'); + expect(updated.body.editable).toBe(true); + }); + + it('rejects an empty PATCH body', async () => { + expect((await patch(authorToken, {})).status).toBe(400); + }); + + it('hides a consultation from another doctor', async () => { + expect((await get(otherDoctorToken)).status).toBe(404); + expect((await signOff(otherDoctorToken)).status).toBe(404); + expect((await addAddendum(otherDoctorToken, 'not mine')).status).toBe(404); + // ...and the note is untouched by those attempts. + const own = await get(authorToken); + expect(own.body.signedAt).toBeNull(); + expect(own.body.addenda).toEqual([]); + }); + + it('refuses addenda before sign-off — the note itself is still editable', async () => { + const early = await addAddendum(authorToken, 'premature addendum'); + expect(early.status).toBe(409); + }); + + it('signs off the note', async () => { + const signed = await signOff(authorToken); + expect(signed.status).toBe(200); + expect(signed.body.signedAt).toBeTruthy(); + expect(signed.body.editable).toBe(false); + }); + + it('refuses a second sign-off', async () => { + expect((await signOff(authorToken)).status).toBe(409); + }); + + it('refuses to edit a signed-off note through the API', async () => { + const edit = await patch(authorToken, { notes: 'tampered after sign-off' }); + expect(edit.status).toBe(409); + + const read = await get(authorToken); + expect(read.body.notes).toBe('Revised assessment'); + }); + + it('rejects a direct UPDATE of a signed-off note at the DB level', async () => { + // The reviewer's test: bypass the repository entirely. + await expect( + prisma.consultation.update({ where: { id: consultationId }, data: { notes: 'bypassed the service' } }), + ).rejects.toThrow(/immutable/i); + + await expect( + prisma.consultation.update({ where: { id: consultationId }, data: { outcome: 'resolved' } }), + ).rejects.toThrow(/immutable/i); + + // Un-signing would make the note mutable again, so it's rejected too. + await expect( + prisma.consultation.update({ where: { id: consultationId }, data: { signedAt: null } }), + ).rejects.toThrow(/immutable/i); + + const row = await prisma.consultation.findUniqueOrThrow({ where: { id: consultationId } }); + expect(row.notes).toBe('Revised assessment'); + expect(row.outcome).toBe('follow_up_needed'); + expect(row.signedAt).not.toBeNull(); + }); + + it('allows addenda after sign-off, in order, and still blocks the author rule', async () => { + const first = await addAddendum(authorToken, 'First correction'); + expect(first.status).toBe(201); + const second = await addAddendum(authorToken, 'Second correction'); + expect(second.status).toBe(201); + + expect((await addAddendum(otherDoctorToken, 'not mine either')).status).toBe(404); + + const read = await get(authorToken); + expect(read.body.addenda.map((a: { body: string }) => a.body)).toEqual(['First correction', 'Second correction']); + // The note itself is unchanged by addenda. + expect(read.body.notes).toBe('Revised assessment'); + expect(read.body.editable).toBe(false); + }); + + it('auto-signs off a draft past the edit window', async () => { + const created = await create(authorToken, { notes: 'Left open too long' }); + expect(created.status).toBe(201); + const staleId = created.body.id as string; + + // Backdate it past the 1-hour window configured for this run. `createdAt` + // is not part of the immutability trigger (only notes/outcome/signedAt), + // so this is an allowed write on a draft. + await prisma.consultation.update({ + where: { id: staleId }, + data: { createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000) }, + }); + + const signedOff = await autoSignOffDueConsultations(); + expect(signedOff).toBeGreaterThanOrEqual(1); + + const row = await prisma.consultation.findUniqueOrThrow({ where: { id: staleId } }); + expect(row.signedAt).not.toBeNull(); + + // And it's genuinely immutable now, not just marked. + expect((await patch(authorToken, { notes: 'too late' }, staleId)).status).toBe(409); + await expect( + prisma.consultation.update({ where: { id: staleId }, data: { notes: 'too late directly' } }), + ).rejects.toThrow(/immutable/i); + }); + + it('leaves a fresh draft alone when the auto-sign-off job runs', async () => { + const created = await create(authorToken, { notes: 'Still fresh' }); + const freshId = created.body.id as string; + + await autoSignOffDueConsultations(); + + const row = await prisma.consultation.findUniqueOrThrow({ where: { id: freshId } }); + expect(row.signedAt).toBeNull(); + expect((await patch(authorToken, { notes: 'edited while drafted' }, freshId)).status).toBe(200); + }); +}); diff --git a/backend/tests/integration/order-fixtures.mts b/backend/tests/integration/order-fixtures.mts index 44e7d8f1..612ddee6 100644 --- a/backend/tests/integration/order-fixtures.mts +++ b/backend/tests/integration/order-fixtures.mts @@ -13,7 +13,9 @@ export async function createOrderFixture( const appointment = await prisma.appointment.create({ data: { patientProfileId: input.patientProfileId, doctorProfileId: doctor.id, availabilitySlotId: slot.id, } }); - const consultation = await prisma.consultation.create({ data: { appointmentId: appointment.id } }); + const consultation = await prisma.consultation.create({ + data: { appointmentId: appointment.id, doctorProfileId: doctor.id }, + }); if (input.kind === 'prescription') { const order = await prisma.prescription.create({ data: { consultationId: consultation.id, patientProfileId: input.patientProfileId, prescribingDoctorId: doctor.id, diff --git a/backend/tests/unit/consultations.test.ts b/backend/tests/unit/consultations.test.ts new file mode 100644 index 00000000..0371c6fc --- /dev/null +++ b/backend/tests/unit/consultations.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + addAddendumSchema, + createConsultationSchema, + updateConsultationSchema, +} from '../../src/modules/consultations/validation'; + +describe('createConsultationSchema', () => { + it('accepts notes with a valid outcome', () => { + expect( + createConsultationSchema.safeParse({ appointmentId: 'appt-1', notes: 'Seen and discharged', outcome: 'resolved' }) + .success, + ).toBe(true); + }); + + it('accepts no notes — a consultation can be opened before it is written up', () => { + expect(createConsultationSchema.safeParse({ appointmentId: 'appt-1' }).success).toBe(true); + }); + + it('rejects a consultation with no appointment — it closes exactly one', () => { + expect(createConsultationSchema.safeParse({}).success).toBe(false); + expect(createConsultationSchema.safeParse({ appointmentId: '' }).success).toBe(false); + }); + + it('rejects an outcome outside the enum', () => { + expect(createConsultationSchema.safeParse({ appointmentId: 'appt-1', outcome: 'cured' }).success).toBe(false); + }); + + it('rejects a blank note', () => { + expect(createConsultationSchema.safeParse({ appointmentId: 'appt-1', notes: ' ' }).success).toBe(false); + }); +}); + +describe('updateConsultationSchema', () => { + it('accepts a partial update', () => { + expect(updateConsultationSchema.safeParse({ notes: 'updated' }).success).toBe(true); + expect(updateConsultationSchema.safeParse({ outcome: 'referred' }).success).toBe(true); + }); + + it('rejects an empty body', () => { + expect(updateConsultationSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('addAddendumSchema', () => { + it('requires a non-empty body', () => { + expect(addAddendumSchema.safeParse({ body: 'Correction' }).success).toBe(true); + expect(addAddendumSchema.safeParse({ body: ' ' }).success).toBe(false); + expect(addAddendumSchema.safeParse({}).success).toBe(false); + }); +});