diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 172c95e..5eb8920 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 b5ec372..ebd86fe 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` → `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 0000000..70088b0 --- /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 2335c33..03426aa 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -334,6 +334,7 @@ model DoctorProfile { specialties DoctorSpecialty[] availabilitySlots AvailabilitySlot[] appointments Appointment[] + consultations Consultation[] prescriptions Prescription[] labOrders LabOrder[] // Referral sides a doctor can sit on: `referringReferrals` are ones @@ -495,22 +496,57 @@ 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()) appointmentId String @unique - outcome ConsultationOutcome? - notes String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - appointment Appointment @relation(fields: [appointmentId], references: [id]) + // The authoring doctor — the only account that may sign off, edit the + // draft, or add addenda. Denormalised from the appointment so the + // ownership check doesn't depend on the appointment row staying put. + doctorProfileId String + doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id]) + + outcome ConsultationOutcome? + notes String? + + // Null = draft (editable). Set = signed off (immutable). + signedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + appointment Appointment @relation(fields: [appointmentId], references: [id]) + addenda ConsultationAddendum[] prescriptions Prescription[] labOrders LabOrder[] // 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/app.ts b/backend/src/app.ts index e57c531..ab3cccf 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,6 +7,7 @@ import { adminAuthRouter, authRouter } from './modules/auth/routes'; import { doctorsRouter } from './modules/doctors/routes'; import { availabilityRouter } from './modules/availability/routes'; import { appointmentsRouter } from './modules/appointments/routes'; +import { consultationsRouter } from './modules/consultations/routes'; import { ordersRouter } from './modules/orders/routes'; import { paymentsRouter } from './modules/payments/routes'; import { patientsRouter } from './modules/patients/routes'; @@ -16,7 +17,6 @@ import { auditRouter, adminAuditRouter, patientAuditRouter } from './modules/aud import { escrowEventsRouter } from './modules/payments/routes/escrow-events'; import { requestId } from './middleware/request-id'; import { referralsRouter } from './modules/referrals/routes'; -import { consultationsRouter } from './modules/consultations/routes'; export function createApp() { const app = express(); @@ -48,6 +48,7 @@ export function createApp() { app.use('/doctors', doctorsRouter); app.use('/availability', availabilityRouter); app.use('/appointments', appointmentsRouter); + app.use('/consultations', consultationsRouter); app.use('/orders', ordersRouter); app.use('/payments', paymentsRouter); app.use('/patients', patientsRouter); @@ -62,7 +63,6 @@ export function createApp() { app.use('/admin', adminAuditRouter); app.use('/patients', patientAuditRouter); app.use('/referrals', referralsRouter); - app.use('/consultations', consultationsRouter); // Must be registered last — Express only routes errors here once every // other handler has had a chance to run. diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 911967f..5ee0a45 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -43,6 +43,16 @@ export const config = { accessTtl: '15m', refreshTtl: '30d', }, + consultations: { + // How long a consultation note stays editable before it's signed off + // automatically (issue #64) — notes shouldn't be editable forever. + // Measured from `createdAt` for now (the note is created against a + // completed appointment); see the caveat in consultations/services. + autoSignoffHours: Number(process.env.CONSULTATION_AUTO_SIGNOFF_HOURS ?? 24), + autoSignoffCheckIntervalMinutes: Number( + process.env.CONSULTATION_AUTO_SIGNOFF_CHECK_INTERVAL_MINUTES ?? 15, + ), + }, // Phase 8 hardening knobs (issues #34/#35/#37). Env-configurable so a // preview deployment can be tightened without a code change, and so // tests can set short windows instead of waiting real minutes. 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 0000000..e5d908f --- /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 1e249bd..2f93dd1 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -111,7 +111,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 53e98e6..b495e44 100644 --- a/backend/src/modules/consultations/controllers/index.ts +++ b/backend/src/modules/consultations/controllers/index.ts @@ -1,27 +1,53 @@ import type { Request, Response } from 'express'; -import * as consultationsService from '../services'; -import { createConsultationSchema, createReferralsSchema } from '../validation'; import { HttpError } from '../../../http-error'; import { getParam } from '../../../request-params'; +import * as consultationsService from '../services'; +import { + addAddendumSchema, + createConsultationSchema, + createReferralsSchema, + updateConsultationSchema, +} from '../validation'; -export async function create(req: Request, res: Response) { +function requireUserId(req: Request): string { 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(req.user.id, input); + const consultation = await consultationsService.createConsultation(requireUserId(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 3ab8404..1493a63 100644 --- a/backend/src/modules/consultations/repositories/index.ts +++ b/backend/src/modules/consultations/repositories/index.ts @@ -1,22 +1,70 @@ +// 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 { ConsultationOutcome, Prisma } from '@prisma/client'; +import type { ConsultationOutcome } 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, - data: { appointmentId: string; outcome?: ConsultationOutcome; notes?: string }, +export function findConsultationById(id: string) { + return prisma.consultation.findUnique({ + where: { id }, + include: { addenda: { orderBy: { createdAt: 'asc' } } }, + }); +} + +/** Only matches while the consultation is unsigned. Returns the update + * count so the service can distinguish "written" from "signed off". */ +export function updateConsultationDraft( + id: string, + data: { notes?: string; outcome?: ConsultationOutcome }, ) { - return db.consultation.create({ data }); + return prisma.consultation.updateMany({ + where: { id, signedAt: null }, + data, + }); } -export function findConsultationById(db: DbClient, id: string) { - return db.consultation.findUnique({ - where: { id }, - include: { appointment: true, referrals: true }, +/** Only matches while unsigned — a second sign-off is a 0-row update, not + * a race that could overwrite the original timestamp. */ +export function signOffConsultation(id: string, signedAt: Date) { + return prisma.consultation.updateMany({ + where: { id, signedAt: null }, + data: { signedAt }, }); } -export function findConsultationByAppointmentId(db: DbClient, appointmentId: string) { - return db.consultation.findUnique({ where: { appointmentId } }); +export function createAddendum(data: { consultationId: string; authorDoctorProfileId: string; body: string }) { + return prisma.consultationAddendum.create({ data }); +} + +export function findAddenda(consultationId: string) { + return prisma.consultationAddendum.findMany({ + where: { consultationId }, + orderBy: { createdAt: 'asc' }, + }); +} + +/** + * Drafts whose edit window has elapsed. `now - autoSignoffHours` is the + * cutoff: anything created before it is signed off automatically so notes + * don't stay editable forever (issue #64). + */ +export function findConsultationsDueForAutoSignOff(cutoff: Date) { + return prisma.consultation.findMany({ + where: { signedAt: null, createdAt: { lte: cutoff } }, + select: { id: true }, + }); } diff --git a/backend/src/modules/consultations/routes/index.ts b/backend/src/modules/consultations/routes/index.ts index 9b10d30..6219174 100644 --- a/backend/src/modules/consultations/routes/index.ts +++ b/backend/src/modules/consultations/routes/index.ts @@ -1,26 +1,23 @@ import { Router } from 'express'; -import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import { authenticate, requireRole } from '../../../middleware/auth'; import * as consultationsController from '../controllers'; export const consultationsRouter = Router(); -// Recording a consultation and spawning referrals from it both hand a -// patient onward with clinical context — verified-address bar, same as -// booking and referral creation itself. -consultationsRouter.post( - '/', - authenticate, - requireRole('doctor'), - requireVerifiedEmail, - consultationsController.create, -); -consultationsRouter.get('/:consultationId', authenticate, consultationsController.getOne); +// Doctor-only in v1: the authoring doctor is the only account wired to these +// (see the flagged limitation in services/index.ts). Patient access to their +// own care thread follows the appointment link that issue #4 owns — not +// invented here. +consultationsRouter.post('/', authenticate, requireRole('doctor'), consultationsController.create); +consultationsRouter.get('/:id', authenticate, requireRole('doctor'), consultationsController.get); +consultationsRouter.patch('/:id', authenticate, requireRole('doctor'), consultationsController.update); +consultationsRouter.post('/:id/sign-off', authenticate, requireRole('doctor'), consultationsController.signOff); +consultationsRouter.post('/:id/addenda', authenticate, requireRole('doctor'), consultationsController.addAddendum); // Issue #66 (PRD §8.2.4): multiple simultaneous referrals from one // consultation, atomic all-or-none, each with its own snapshot copy. 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 6ed8e8c..9e74679 100644 --- a/backend/src/modules/consultations/services/index.ts +++ b/backend/src/modules/consultations/services/index.ts @@ -1,65 +1,193 @@ -// Consultations services — minimal Phase-3 slice (issue #5's lineage -// host + issue #66's multi-referral endpoint). Full consultation record -// workflow (issue #4: sign-off, addenda) stays open; this exists so a -// referral has a consultation to grow out of and a lineage to walk. -import { prisma } from '../../../db/prisma-client'; +// consultations services — the only exported surface other modules (and this +// module's own controllers) may call. +// +// Issue #64: a consultation is a draft until sign-off, then immutable with +// append-only addenda. Authorisation is deliberately narrow — the authoring +// doctor is the only account that can read, edit, sign off or add addenda +// until the appointment/patient link exists (Phase 2 #4). That's a flagged +// limitation, not an assumption that others shouldn't see it (PRD §7). +import { config } from '../../../config'; import { HttpError } from '../../../http-error'; -import type { ConsultationOutcome } from '@prisma/client'; -import * as consultationsRepo from '../repositories'; -import * as referralsService from '../../referrals/services'; import * as doctorsService from '../../doctors/services'; -import * as patientsService from '../../patients/services'; +import * as appointmentsService from '../../appointments/services'; +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; +} -/** Doctor records a consultation against their own completed appointment. - * One consultation per appointment (unique appointmentId) — a re-visit is - * a new appointment, not a second row here. */ export async function createConsultation( userId: string, - input: { appointmentId: string; outcome?: ConsultationOutcome; notes?: string }, -) { - const doctor = await doctorsService.getMyDoctorProfile(userId); - - return prisma.$transaction(async (tx) => { - const appointment = await tx.appointment.findUnique({ where: { id: input.appointmentId } }); - if (!appointment) throw new HttpError(404, 'Appointment not found.'); - if (appointment.doctorProfileId !== doctor.id) { - throw new HttpError(403, 'You can only record consultations for your own appointments.'); - } - if (appointment.status !== 'completed') { - throw new HttpError(409, 'A consultation is recorded against a completed appointment.'); - } - const existing = await consultationsRepo.findConsultationByAppointmentId(tx, input.appointmentId); - if (existing) { - throw new HttpError(409, 'A consultation already exists for this appointment.'); - } - return consultationsRepo.createConsultation(tx, { - appointmentId: input.appointmentId, - outcome: input.outcome, - notes: input.notes, - }); + input: { appointmentId: string; notes?: string; outcome?: ConsultationOutcome }, +): Promise { + // The appointment is what a consultation closes (one per appointment, + // unique). Resolved through the appointments module's service, which + // also proves the appointment belongs to this doctor — never by reading + // another module's table (AGENTS.md hard rule 4). + const appointment = await appointmentsService.getDoctorAppointmentContext(userId, input.appointmentId); + // One consultation per appointment, recorded once the visit is over — a + // re-visit is a new appointment, not a second note on the same one. + if (appointment.status !== 'completed') { + throw new HttpError(409, 'A consultation is recorded against a completed appointment.'); + } + const consultation = await consultationsRepo.createConsultation({ + appointmentId: appointment.id, + doctorProfileId: appointment.doctorProfileId, + notes: input.notes, + outcome: input.outcome, }); + return toReadModel({ ...consultation, addenda: [] }); } -/** Visible to the treating doctor and the patient of the underlying - * appointment — nothing wider in v1. */ -export async function getConsultation(userId: string, consultationId: string) { - const consultation = await consultationsRepo.findConsultationById(prisma, consultationId); - if (!consultation) throw new HttpError(404, 'Consultation not found.'); +export async function getConsultation(userId: string, consultationId: string): Promise { + return toReadModel(await requireOwnConsultation(userId, consultationId)); +} + +/** + * Edits a draft. A signed-off consultation is immutable at two levels: this + * refuses with a 409, and the DB trigger rejects the UPDATE even if this is + * bypassed. + */ +export async function updateConsultation( + userId: string, + consultationId: string, + input: { notes?: string; outcome?: ConsultationOutcome }, +): Promise { + const consultation = await requireOwnConsultation(userId, consultationId); + if (consultation.signedAt) { + throw new HttpError( + 409, + 'This consultation is signed off and can no longer be edited. Add an addendum instead.', + ); + } + + const result = await consultationsRepo.updateConsultationDraft(consultationId, input); + // Lost a race against auto sign-off / a concurrent sign-off between the + // read above and this write. + if (result.count !== 1) { + throw new HttpError(409, 'This consultation was signed off while you were editing it.'); + } + + return toReadModel(await requireOwnConsultation(userId, consultationId)); +} + +export async function signOffConsultation( + userId: string, + consultationId: string, + at: Date = new Date(), +): Promise { + await requireOwnConsultation(userId, consultationId); + + const result = await consultationsRepo.signOffConsultation(consultationId, at); + if (result.count !== 1) throw new HttpError(409, 'This consultation is already signed off.'); + + return toReadModel(await requireOwnConsultation(userId, consultationId)); +} + +/** + * Post-sign-off corrections. Only the authoring doctor may add one, and only + * to a signed-off consultation — before sign-off the note itself is still + * editable, so an addendum would just be a confusing second draft. + */ +export async function addAddendum( + userId: string, + consultationId: string, + body: string, +): Promise { + const consultation = await requireOwnConsultation(userId, consultationId); + + if (!consultation.signedAt) { + throw new HttpError(409, 'Addenda are only for signed-off consultations — edit the note directly while it is a draft.'); + } + + await consultationsRepo.createAddendum({ + consultationId, + authorDoctorProfileId: consultation.doctorProfileId, + body, + }); + + return toReadModel(await requireOwnConsultation(userId, consultationId)); +} + +/** + * Signs off every draft older than the configured window. Runs from the + * interval job in src/jobs (kept as a plain interval per + * ARCHITECTURE.md §6.3.2 — no queue until a real job needs one). + * + * Caveat, flagged: the window is measured from `createdAt`, not from the + * appointment's completion time — `Appointment` has no completed-at stamp + * yet (#4 owns the appointment lifecycle). + */ +export async function autoSignOffDueConsultations(now: Date = new Date()): Promise { + const cutoff = new Date(now.getTime() - config.consultations.autoSignoffHours * 60 * 60 * 1000); + const due = await consultationsRepo.findConsultationsDueForAutoSignOff(cutoff); - if (consultation.appointment) { - const doctor = await doctorsService.getMyDoctorProfile(userId).catch(() => null); - if (doctor && doctor.id === consultation.appointment.doctorProfileId) return consultation; - const patient = await patientsService.getMyPatientProfile(userId).catch(() => null); - if (patient && patient.id === consultation.appointment.patientProfileId) return consultation; + let signedOff = 0; + for (const consultation of due) { + const result = await consultationsRepo.signOffConsultation(consultation.id, now); + signedOff += result.count; } - 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 fc47e98..ac10338 100644 --- a/backend/src/modules/consultations/validation.ts +++ b/backend/src/modules/consultations/validation.ts @@ -1,12 +1,32 @@ import { z } from 'zod'; import { referralTargetSchema } from '../referrals/validation'; +const outcomeSchema = z.enum(['resolved', 'referred', 'follow_up_needed']); + 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/modules/orders/repositories/index.ts b/backend/src/modules/orders/repositories/index.ts index 9861fcd..d2576f6 100644 --- a/backend/src/modules/orders/repositories/index.ts +++ b/backend/src/modules/orders/repositories/index.ts @@ -3,7 +3,7 @@ import type { Prisma } from '@prisma/client'; type DbClient = typeof prisma | Prisma.TransactionClient; -export function createConsultation(db: DbClient, input: { appointmentId: string; outcome: 'resolved' | 'referred' | 'follow_up_needed' | null; notes: string | null }) { +export function createConsultation(db: DbClient, input: { appointmentId: string; doctorProfileId: string; outcome: 'resolved' | 'referred' | 'follow_up_needed' | null; notes: string | null }) { return db.consultation.create({ data: input }); } diff --git a/backend/src/modules/orders/services/index.ts b/backend/src/modules/orders/services/index.ts index 63ba541..63257c4 100644 --- a/backend/src/modules/orders/services/index.ts +++ b/backend/src/modules/orders/services/index.ts @@ -17,6 +17,7 @@ export async function createConsultation(actor: auditService.AuditActor, input: return await prisma.$transaction(async (tx) => { const consultation = await ordersRepo.createConsultation(tx, { ...input, + doctorProfileId: appointment.doctorProfileId, notes: encryptField(input.notes) ?? null, }); await auditService.record(tx, actor, { diff --git a/backend/src/server.ts b/backend/src/server.ts index fa1c957..62c6350 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 { verifyPaymentsNetwork } from './modules/payments/services'; @@ -63,3 +64,6 @@ pruneTimer.unref(); // Patient data export worker + expiry cleanup (Phase 8, issue #40). startPatientExportJobs(); startEscrowEventPoller(); +// Consultation drafts past their edit window are signed off automatically +// (issue #64). +startConsultationAutoSignOffJob(); diff --git a/backend/tests/integration/consultations.test.mts b/backend/tests/integration/consultations.test.mts new file mode 100644 index 0000000..407b89b --- /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 42cf62d..17494ea 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 0000000..0371c6f --- /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); + }); +});