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` (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)
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"();
28 changes: 26 additions & 2 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
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 @@ -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,
};
}

/**
Expand Down
38 changes: 33 additions & 5 deletions backend/src/modules/consultations/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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,
);
Expand Down
17 changes: 16 additions & 1 deletion backend/src/modules/consultations/repositories/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
3 changes: 1 addition & 2 deletions backend/src/modules/consultations/routes/index.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -42,6 +42,5 @@ consultationsRouter.post(
'/:consultationId/referrals',
authenticate,
requireRole('doctor'),
requireVerifiedEmail,
consultationsController.createReferrals,
);
63 changes: 60 additions & 3 deletions backend/src/modules/consultations/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down
20 changes: 19 additions & 1 deletion backend/src/modules/consultations/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Loading
Loading