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
12 changes: 9 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# ROADMAP.md — DocForum

Last updated: 2026-10-22
Status: Phase 0, Phase 1, and Phase 5.5 (Payments, custodial v1 — see
`docs/adr/0004-custodial-payments-v1.md`) built and tested. Phase 3
(Referrals) is **in progress**: #5, #7 and #65–#68 implemented and
Expand Down Expand Up @@ -138,7 +139,7 @@ and issue links.
- [ ] Decide and document what's explicitly out of scope for v1 launch vs. genuinely blocking (e.g., drug-interaction checking, PRD §8.2.8 — needs an explicit go/no-go, not silence). Tracked as [issue #20](https://github.com/DocForum/docforum-core/issues/20) (Trivial, 100 pts) — a decision task, unblocked now.

## Phase 9 — Deploy & CI
**Status: In progress. Structured JSON logging (request IDs + PHI redaction) landed; load tests (k6) for search, slot listing and booking contention added; automated Postgres backups with a tested restore drill are now implemented (issue #107). A Render free-tier preview exists — see note. Production deploy still Not started. Remaining items scoped into issues #21–#22.**
**Status: In progress. Structured JSON logging (request IDs + PHI redaction) landed; load tests (k6) for search, slot listing and booking contention added; automated Postgres backups with a tested restore drill are now implemented (issue #107). A Render free-tier preview exists — see note. Production deploy still Not started. Remaining items scoped into issues #21–#22. Machine-readable error codes and a documented error catalogue are implemented (see changelog).**
- [x] Localization foundation ([issue #133](https://github.com/DocForum/docforum-core/issues/133)): persist a supported locale from `Accept-Language` at signup, format coded errors and password-reset notifications with ICU catalogs (English source + French stub), and enforce catalog key parity in CI. Other server messages remain English until their owning flows adopt the catalog.
- [x] **PR malware scan** — `.github/workflows/pr-malware-scan.yml` + `.github/scripts/pr-malware-scan.cjs`, on every PR push. Flags editor/devcontainer autorun tasks, npm install hooks, typosquatted packages and Actions, non-registry dependency sources, payloads disguised as fonts/images, base64/whitespace obfuscation, exfiltration endpoints, reverse shells and miners. HIGH findings fail the `Malware scan` check, label the PR `security-review` and @mention the author; one comment per PR, updated in place. Reads the PR as data only (never checks out or runs it). Added 2026-10-20 after a VS Code autorun payload (escrow #163) and a typosquatted action (escrow #176). Manual backfill (Actions → PR malware scan → Run workflow, `prs: all`) scans PRs opened before the scanner and records a `Malware scan` commit status, which satisfies the required check.
- [ ] End-to-end care-thread integration test: book → consult → refer → accept → order → fulfil → release. A single self-contained test (`backend/tests/integration/care-thread.test.mts`) driving the full thread through HTTP only, against embedded Postgres with the escrow SDK mocked; asserts the key invariants at each step (snapshot equality, append-only orders, facility-only fulfillment, payment status) rather than just status codes. Steps are phase-gated — each phase's steps are skipped with a reason until that phase lands, so the scaffold and already-built steps (booking, consult, refer, accept) run now and the rest enable as Phases 4/5 land. Runs as part of `test:integration` in CI, runtime kept under a minute. Tracked as [issue #69](https://github.com/DocForum/docforum-core/issues/69) (High, 200 pts) — **in progress**, branch `feat/end-to-end-care-thread-integration-test-book`.
Expand All @@ -153,6 +154,7 @@ and issue links.
- [x] **Load tests (k6)** — `backend/tests/load/` (`search.js`, `slots.js`, `booking-contention.js`), `backend/scripts/seed-load-test.mjs` (thousands of doctors + slots), and `.github/workflows/load-tests.yml` (manual/nightly, never against the shared Render preview). Thresholds enforce p95 latency on read paths and zero double-bookings; the contention scenario asserts correctness from the database afterwards (exactly one booking per slot), not just response codes. First results committed in `backend/tests/load/REPORT.md`.

- [ ] Fixed-seed property tests for slot generation and appointment/payment state machines, with an expanded nightly run. Tracked as [issue #120](https://github.com/DocForum/docforum-core/issues/120).
- [x] Machine-readable error codes and a documented error catalogue. `HttpError` and the error envelope extended to `{ code, message, details? }` with a per-module `ErrorCode` enum (e.g. `SLOT_TAKEN`, `EMAIL_NOT_VERIFIED`, `WALLET_NOT_LINKED`); every thrown error in existing modules converted to a coded one; Zod validation failures collapse to a single `VALIDATION_FAILED` code with field details; unknown errors return `INTERNAL` with no internals leaked. `message` kept for backwards compatibility. Catalogue generated from the enum into `docs/api/` (code, HTTP status, meaning, client guidance). Tests assert codes for the key failures (slot-taken `409`, unverified email, wallet-not-linked, validation, internal).

## Explicitly deferred (not on this roadmap, tracked so they aren't forgotten)
- In-app video/voice consultation (PRD OQ-1 — v1 assumes in-person + async messaging).
Expand Down Expand Up @@ -280,7 +282,7 @@ and issue links.
check must pass and one approval is required before merge, force
pushes and deletion are disabled. `enforce_admins` left `false` so
the maintainer isn't blocked — this protects against a bad external
PR landing unreviewed/uncheck-passing, not against the maintainer's
PR landing unreviewed/check-passing, not against the maintainer's
own direct pushes.
- Added GitHub topics to `docforum-core` and `docforum-web`
(`docforum-escrow` already had them) for discoverability.
Expand Down Expand Up @@ -378,7 +380,8 @@ and issue links.
sponsorship. Custodial v1 stays as a fallback. Status: Proposed, not
yet Accepted. Added to the docs site.
- 2026-10-09 — Opened issue #32: upgrade to escrow SDK 0.3.0 and the
current testnet contract.- 2026-10-16 — Implemented issue #77 (SEP-10 Stellar Web Authentication server, Phase 5.5 / ADR 0005 step 4): `GET
current testnet contract.
- 2026-10-16 — Implemented issue #77 (SEP-10 Stellar Web Authentication server, Phase 5.5 / ADR 0005 step 4): `GET
/payments/sep10/challenge` builds a server-signed challenge (SDK
helper — Manage Data nonce, 5-minute time bounds, home domain +
web_auth_domain) and records the issuance binding; `POST
Expand Down Expand Up @@ -571,5 +574,8 @@ and issue links.
payouts as the default in production and local environments, with the
configuration controlled by `PAYOUT_PRIVACY_MODE` and
`PAYOUT_PRIVACY_POOL_ADDRESS`.
- 2026-10-21 — Phase 9: machine-readable error codes and a documented error catalogue. `HttpError` and the error envelope are now `{ code, message, details? }` with a per-module `ErrorCode` enum; every thrown error in existing modules (auth, doctors, availability, appointments, consultations, referrals, payments, patients/export, admin/audit) is coded. Zod validation errors collapse to a single `VALIDATION_FAILED` code carrying field details; unknown errors return `INTERNAL` with no internals leaked; `message` is retained for backwards compatibility. The catalogue is generated from the enum into `docs/api/` (code, HTTP status, meaning, client guidance) so it can't drift from the code. Integration tests assert the codes for the key failures the frontend needs to branch on — notably the `409 SLOT_TAKEN` case `docforum-web` issue #2 depends on, plus `EMAIL_NOT_VERIFIED`, `WALLET_NOT_LINKED`, `VALIDATION_FAILED` and `INTERNAL`. `docs/api/README.md` updated to document the envelope shape and link the catalogue.
- 2026-10-22 — Fixed the syntax errors CI reported in the Phase 9 error-code work: `backend/src/modules/appointments/services/index.ts` and `backend/src/modules/orders/services/index.ts` had malformed `HttpError` construction (unterminated string literals and stray identifiers around the new `code` argument), which failed the Syntax and Boot / Jest load check before push. Corrected the call sites to pass the `ErrorCode` enum member as the first argument and the message string as the second, matching the `HttpError` signature; no other helpers, exports or formatting touched.
- 2026-10-22 — Re-fixed the remaining syntax errors CI reported in `backend/src/modules/orders/services/index.ts` (TS1109 at lines 77, 82, 116, 121): the earlier correction missed four `HttpError` call sites where the `code` argument was still malformed. Corrected those call sites to pass the `ErrorCode` enum member first and the message string second, matching the `HttpError` signature; no other helpers, exports or formatting touched.
- 2026-10-22 — Closed issue #19 (held-slot TTL/release job) as **not applicable as currently designed**, per the issue's own allowance for that outcome. Investigation: Phase 1's `claimOpenSlot` claims a slot directly with a conditional `UPDATE` inside the booking transaction and has no intermediate `held` status, so there is no abandoned-hold risk to bound — confirmed against `backend/tests/integration/booking.test.mts` (8 concurrent attempts, exactly 1 succeeds) and the absence of any `held`/`holdExpiresAt` field in `schema.prisma`. No hold state was introduced just to have something to release. Documented in the Phase 8 checklist; revisit if a multi-step booking flow (e.g. reserving a slot during Phase 2 intake) introduces a hold.
- 2026-10-22 — Fixed the syntax error in `backend/src/modules/availability/repositories/index.ts` (TS1110, "Type expected" at line 31) that failed the local syntax check before push. The malformed type annotation in the repository's export was corrected in place; no other identifiers, exports, or formatting in that file were touched, and the held-slot investigation above is unchanged.
63 changes: 57 additions & 6 deletions backend/src/http-error.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,66 @@
// Shared across modules — the one place an HTTP-status-bearing error is
// defined, so error-handler.ts has a single shape to check for.

// Machine-readable error codes. Clients branch on these instead of
// string-matching `message`, so messages can change freely. Grouped by
// module; `VALIDATION_FAILED` and `INTERNAL` are cross-cutting.
export enum ErrorCode {
// Cross-cutting
VALIDATION_FAILED = 'VALIDATION_FAILED',
INTERNAL = 'INTERNAL',

// Auth / accounts
EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
WALLET_NOT_LINKED = 'WALLET_NOT_LINKED',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',

// Bookings / slots
SLOT_TAKEN = 'SLOT_TAKEN',
SLOT_NOT_FOUND = 'SLOT_NOT_FOUND',
SLOT_DOCTOR_MISMATCH = 'SLOT_DOCTOR_MISMATCH',
BOOKING_NOT_FOUND = 'BOOKING_NOT_FOUND',
APPOINTMENT_NOT_FOUND = 'APPOINTMENT_NOT_FOUND',
APPOINTMENT_NOT_SCHEDULED = 'APPOINTMENT_NOT_SCHEDULED',
NOT_APPOINTMENT_DOCTOR = 'NOT_APPOINTMENT_DOCTOR',
NOT_APPOINTMENT_PATIENT = 'NOT_APPOINTMENT_PATIENT',
DOCTOR_NOT_VERIFIED = 'DOCTOR_NOT_VERIFIED',

// Orders / payments
ORDER_NOT_FOUND = 'ORDER_NOT_FOUND',
PAYMENT_FAILED = 'PAYMENT_FAILED',
INVALID_AMOUNT = 'INVALID_AMOUNT',
CONSULTATION_ALREADY_EXISTS = 'CONSULTATION_ALREADY_EXISTS',
CONSULTATION_NOT_FOUND = 'CONSULTATION_NOT_FOUND ',
INVALID_PRESCRIPTION_CORRECTION = 'INVALID_PRESCRIPTION_CORRECTION ',
INVALID_LAB_ORDER_CORRECTION = 'INVALID_LAB_ORDER_CORRECTION',
ORDER_ALREADY_HAS_CORRECTION_OR_PAYMENT = 'ORDER_ALREADY_HAS_CORRECTION_OR_PAYMENT',

// Referrals
REFERRAL_INVALID = 'REFERRAL_INVALID',

// Generic
NOT_FOUND = 'NOT_FOUND',
CONFLICT = 'CONFLICT',
}

export interface HttpErrorDetails {
[key: string]: unknown;
}

export class HttpError extends Error {
constructor(
public readonly status: number,
message: string,
// Optional machine-readable discriminator, for cases where the client
// has to branch on *why* rather than render the message (e.g.
// `EMAIL_NOT_VERIFIED` — Phase 8 issue #34). Additive: the error
// envelope keeps its existing `message` field, so docforum-web's
// api-client parsing is unaffected.
public readonly code?: string,
// Machine-readable discriminator, for cases where the client has to
// branch on *why* rather than render the message (e.g. `SLOT_TAKEN`,
// `EMAIL_NOT_VERIFIED`). Additive: the error envelope keeps its
// existing `message` field, so docforum-web's api-client parsing is
// unaffected.
public readonly code: ErrorCode | string = ErrorCode.INTERNAL,
// Optional structured context (e.g. Zod field errors for
// `VALIDATION_FAILED`). Never contains internals for `INTERNAL`.
public readonly details?: HttpErrorDetails,
) {
super(message);
this.name = 'HttpError';
Expand Down
49 changes: 43 additions & 6 deletions backend/src/middleware/error-handler.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import type { NextFunction, Request, Response } from 'express';
import { ZodError } from 'zod';
import { Prisma } from '@prisma/client';
import { HttpError } from '../http-error';
import { HttpError, ErrorCode } from '../http-error.js';
import type { Logger } from 'pino';
import { logger as rootLogger } from '../logger';
import { localizedErrorMessage, resolveLocale } from '../i18n';

// docforum-web's api-client.ts assumes an error envelope of
// `{ message: string }` (documented as unconfirmed there) — this is the
// implementation that assumption is checked against. Keep them in sync.
//
// The envelope is now always `{ code, message, details? }`. `message` is
// kept for backwards compatibility; clients should branch on `code`.
export interface ErrorEnvelope {
code: ErrorCode;
message: string;
details?: unknown;
}

export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) {
const requestLogger: Logger =
(req as Request & { log?: Logger }).log ?? rootLogger;
Expand All @@ -23,24 +32,52 @@ export function errorHandler(err: unknown, req: Request, res: Response, _next: N
}
const locale = resolveLocale(req.user?.locale, req.headers['accept-language']);
const message = localizedErrorMessage(locale, err.code, err.message);
res.status(err.status).json(err.code ? { message, code: err.code } : { message });
const body: ErrorEnvelope = {
code: err.code,
message,
};
if (err.details !== undefined) {
body.details = err.details;
}
res.status(err.status).json(body);
return;
}

if (err instanceof ZodError) {
requestLogger.warn({ err }, 'Request validation failed');
res.status(400).json({ message: err.issues.map((issue) => issue.message).join('; ') });
// All validation failures share a single code; field level details are
// exposed via `details` so clients can highlight specific inputs.
const body: ErrorEnvelope = {
code: ErrorCode.VALIDATION_FAILED,
message: err.issues.map((issue) => issue.message).join('; '),
details: err.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
};
res.status(400).json(body);
return;
}

if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
requestLogger.warn({ err }, 'Unique constraint violation');
res.status(409).json({ message: 'A record with this value already exists.' });
// DB-level uniqueness guarantees are the backstop for booking and
// slot assignment races. Surface them as a coded conflict.
const body: ErrorEnvelope = {
code: ErrorCode.CONFLICT,
message: 'A record with this value already exists.',
};
res.status(409).json(body);
return;
}

// Log the full error (including stack) server-side only. The response
// body is the safe envelope and never leaks internal details.
requestLogger.error({ err }, 'Unhandled error');
res.status(500).json({ message: 'Internal server error.' });
}
const body: ErrorEnvelope = {
code: ErrorCode.INTERNAL,
message: 'Internal server error.',
};
res.status(500).json(body);
}
Loading
Loading