Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"();
50 changes: 43 additions & 7 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions backend/src/jobs/consultation-auto-sign-off.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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;
}
7 changes: 6 additions & 1 deletion backend/src/modules/appointments/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

/**
Expand Down
44 changes: 35 additions & 9 deletions backend/src/modules/consultations/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -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,
);
Expand Down
Loading
Loading