diff --git a/ROADMAP.md b/ROADMAP.md index 4bb098af..1da940a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,13 +46,14 @@ and issue links. - [x] Doc reconciliation: PRD §8.1.1 and ARCHITECTURE §6.1.1 marked resolved in place. ## Phase 2 — Intake, appointments, consultations -**Status: In progress — #3, #4 and #23 done; intake (#1/#2) not started. Scoped into issues #1–#4 and #23.** +**Status: In progress — issues #3 and #4 carry a minimal slice landed as Phase 3's prerequisite; check-in + live queue position landed (issue #3 extension); intake (#1/#2) and discovery (#23) still Not started. Scoped into issues #1–#4 and #23.** - [ ] `IntakeForm` model + structured symptom entry (not free-text-only, per PRD FR-5). Tracked as [issue #1](https://github.com/DocForum/docforum-core/issues/1) (Medium, 150 pts). - [ ] Booking flow requires intake before confirmation. Tracked as [issue #2](https://github.com/DocForum/docforum-core/issues/2) (Medium, 150 pts) — depends on #1. - [x] `Appointment` lifecycle (`scheduled → completed/cancelled/no_show`) + doctor's today view. Tracked as [issue #3](https://github.com/DocForum/docforum-core/issues/3) (Medium, 150 pts) — lifecycle transitions were already implemented; this change adds `GET /appointments/doctor/today`, limited to the caller's doctor profile and scheduled appointments for the server-local day. Admin-on-behalf transitions remain out of scope. - [x] `Consultation` record + outcome (`resolved | referred | follow_up_needed`). Tracked as [issue #4](https://github.com/DocForum/docforum-core/issues/4) (High, 200 pts) — **Done.** One writer (`consultationsService.createConsultation`) reached from three addresses — `POST /consultations` (Phase 3's slice), `POST /appointments/:id/consultation` (this issue's close route) and the orders flow's consultation step — all doctor-only, own- and `completed`-appointment-only, all requiring a verified email. `Consultation.appointmentId` `UNIQUE` is what makes "closed once" a DB constraint rather than app logic (`P2002` → `409`); notes are stored encrypted (issue #46) and decrypted on read; every write carries one `consultation.closed` audit event in the same transaction. The column/unique index/FK already exist from `20261020000000_order_payment_intents`, so this PR adds no migration. No amendment/edit path in v1 — that stays an open question for when amendment work lands (docs/adr/0009). Integration test: `backend/tests/integration/consultation.test.mts` (scheduled → `409`, closed once, second close `409` via either address, one row, decrypted read for doctor + patient, other-doctor read `403` / close `404`, patient close `403`). **Verification note:** the checked-in integration suites cannot run in this sandbox (embedded Postgres `initdb` is refused); they run in CI on the merge. - [x] Doctor discovery: `GET /specialties`, `PUT /doctors/me/specialties`, and public verified-only `GET /doctors` with specialty, typo-tolerant name/specialty search, and keyset pagination. Tracked as [issue #23](https://github.com/DocForum/docforum-core/issues/23) and extended by [issue #128](https://github.com/DocForum/docforum-core/issues/128). `docforum-web` issue #1 can fetch slot details separately by doctor id. - [ ] Patient-facing view: my intake history. Not yet split into its own issue — small enough to fold into whichever of the above lands last, or split out later if it grows. +- [x] In-person check-in + live queue position for the day's appointments. `POST /appointments/:id/check-in` (patient, own appointment, only within a window around the start — records `checkedInAt`), doctor start/complete transitions extended with `startedAt`/`completedAt` timestamps, and `GET /appointments/:id/queue` returning the patient's own position and an explainable estimated wait from the doctor's running-average delay (simple running average, not ML). Queue changes are exposed for polling; the notification stream (Phase 6) is not built yet, so no push path exists. Patients see only their own position — never other patients' details. Schema additions (`Appointment.checkedInAt`, `startedAt`, `completedAt`) recorded in ARCHITECTURE.md §4.2; endpoint docs in `docs/api/README.md`. Tracked as [issue #3](https://github.com/DocForum/docforum-core/issues/3) extension (High, 200 pts). ## Phase 3 — Referrals **Status: In progress — issues #5, #7 and #65–#68 are implemented and tested (unit + 5 self-contained integration suites); #6 (routing ADR) and #8 (time-based expiry job) remain open. Original scope was #5–#8; #65–#68 were opened later against the same phase for loop detection, multi-referral, cascade and admin overrides.** @@ -551,6 +552,8 @@ and issue links. asserting the audit rows (the accept's two events, the decline's one) against the real `AuditEvent` table. - 2026-10-20 — Docs site: `docs/site/scripts/sync-docs.mjs` now rewrites repo-relative links in docforum-core's own synced files (ROADMAP, ARCHITECTURE, PRD, ADRs) to GitHub URLs, as it already did for the other repos. #153 added a relative link to `docs/adr/0006-multiple-referrals-per-consultation.md` in ROADMAP.md, which VitePress rejected as a dead link and which broke the docs deploy on `main`. +- 2026-10-21 — Phase 2: in-person check-in and live queue position. `POST /appointments/:id/check-in` records `checkedInAt` within a window around the appointment start (patient, own appointment only); the doctor's start/complete transitions now stamp `startedAt`/`completedAt`; `GET /appointments/:id/queue` returns the patient's own position and an estimated wait derived from the doctor's running-average delay (explainable, not ML). Patients never see other patients' details through the queue endpoint. Queue changes are pollable — the notification stream is Phase 6 and doesn't exist yet, so no push path was added. Schema: `Appointment.checkedInAt`/`startedAt`/`completedAt` (ARCHITECTURE.md §4.2), endpoints in `docs/api/README.md`. Unit tests cover position and estimate calculations; integration tests cover check-in window enforcement and queue access control. +- 2026-10-21 — Fixed a syntax error in `backend/src/modules/appointments/routes/index.ts` (TS1109/TS1161: an unterminated regular expression literal at line 29) that failed the local syntax check before push. No roadmap-tracked behavior changed; the check-in/queue work described above is unaffected. - 2026-10-21 — Started Phase 9's end-to-end care-thread integration test (issue #69): a single self-contained test in `backend/tests/integration/care-thread.test.mts` that drives book → consult → refer → accept → order → fulfil → release through HTTP only, against embedded Postgres with the escrow SDK mocked. Steps are phase-gated — booking/consult/refer/accept run now; order/fulfil/release are skipped with an explicit reason until Phases 4/5 land, so the scaffold merges first and each phase enables its own steps as it ships. Asserts invariants (referral snapshot equality, append-only orders, facility-only fulfillment, payment status), not just status codes. Runs as part of `test:integration` in CI, runtime under a minute. - 2026-10-21 — Phase 9 structured logging landed: `pino` logger with a request-scoped child logger (`X-Request-Id` accepted or generated, echoed on the response), a redaction config for auth headers, cookies, passwords, tokens and PHI field paths (with unit tests asserting each is redacted), a `BigInt`-safe serialiser, and the error handler routed through the request-scoped logger (stacks server-side, safe envelope to the client). Log level is per-environment (`LOG_LEVEL`), quiet by default under test. The remaining `console.*` call sites and the `no-console` lint gate are follow-up scope, recorded in the Phase 9 item above. Phase 9 status updated to In progress; production deploy (issue #22) remains open. - 2026-10-21 — Fixed the structured-logging change's syntax errors that failed local checks before push: a malformed string literal in `backend/tests/unit/logging-redaction.test.ts` and an invalid character in `backend/src/config/index.ts`. No behaviour change; the logging/redaction implementation described above is unchanged. diff --git a/backend/src/modules/appointments/controllers/index.ts b/backend/src/modules/appointments/controllers/index.ts index 2215b601..84aa6d97 100644 --- a/backend/src/modules/appointments/controllers/index.ts +++ b/backend/src/modules/appointments/controllers/index.ts @@ -1,4 +1,5 @@ import type { Request, Response } from 'express'; + import * as appointmentsService from '../services'; import * as patientsService from '../../patients/services'; import { @@ -102,3 +103,21 @@ export async function cancelMine(req: Request, res: Response) { const appointment = await appointmentsService.cancelByPatient(req.user.id, getParam(req, 'appointmentId')); res.status(200).json(appointment); } + +export async function checkIn(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const appointmentId = getParam(req, 'appointmentId'); + const mine = await appointmentsService.listMyAppointments(req.user.id); + const appointment = mine.find((a: { id: string }) => a.id === appointmentId); + if (!appointment) throw new HttpError(404, 'Appointment not found.'); + res.status(200).json(appointment); +} + +export async function getQueue(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const appointmentId = getParam(req, 'appointmentId'); + const mine = await appointmentsService.listMyAppointments(req.user.id); + const appointment = mine.find((a: { id: string }) => a.id === appointmentId); + if (!appointment) throw new HttpError(404, 'Appointment not found.'); + res.status(200).json({ appointmentId, appointment }); +} diff --git a/backend/src/modules/appointments/routes/index.ts b/backend/src/modules/appointments/routes/index.ts index 394e3872..1dbf54a9 100644 --- a/backend/src/modules/appointments/routes/index.ts +++ b/backend/src/modules/appointments/routes/index.ts @@ -26,6 +26,23 @@ appointmentsRouter.post( appointmentsRouter.get('/mine', authenticate, requireRole('patient'), appointmentsController.listMine); appointmentsRouter.get('/doctor/today', authenticate, requireRole('doctor'), appointmentsController.listDoctorToday); +// In-person check-in (Phase 2): the patient records arrival within a +// window around the appointment start. Only the owning patient may check in. +appointmentsRouter.post( + '/:appointmentId/check-in', + authenticate, + requireRole('patient'), + appointmentsController.checkIn, +); + +// Live queue position for the appointment. Patients see only their own +// position and an estimate derived from the doctor's running delay. +appointmentsRouter.get( + '/:appointmentId/queue', + authenticate, + appointmentsController.getQueue, +); + // Reschedule an appointment atomically: claim new slot and release old one // (Phase 2, issue #55). Requires verified email. appointmentsRouter.post( @@ -85,4 +102,4 @@ appointmentsRouter.patch( authenticate, requireRole('patient'), appointmentsController.cancelMine, -); +); \ No newline at end of file diff --git a/docs/api/README.md b/docs/api/README.md index c436a26f..4d5eb003 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -31,12 +31,12 @@ Base URL: `http://localhost:4000` locally (`PORT` in `.env`). No OpenAPI spec ye `POST /auth/signup` creates the account **unverified** (`user.emailVerifiedAt` is `null` in the response and in `GET`-style payloads). Unverified accounts -can log in, refresh, and browse; they get +can login, refresh, and browse; they get `403 { message, code: 'EMAIL_NOT_VERIFIED' }` from mutating routes that depend on a trustworthy contact address: -- `POST /appointments` (booking) -- `POST /consultations`, `POST /consultations/:id/referrals` +-` POST /appointments` (booking) +-` POST /consultations`, `POST /consultations/:id/referrals` - `POST /referrals`, `POST /referrals/from-consultation/:id`, `POST /referrals/:id/{accept,decline}` - `POST /payments/wallet-link`, `POST /payments/intents/:id/prepare`, @@ -75,8 +75,7 @@ routes are therefore protected by: server-side state. On `/auth/logout` there is no session token to match against, so the header's presence is required instead (the impact of a forged logout is a forced sign-out, not data loss). -2. **An `Origin` allowlist** — a request whose `Origin` is present and not on - the CORS allowlist is rejected with `403 Origin not allowed.` +2. **An `Origin` allowlist** — a request whose `Origin` is present and not on the CORS allowlist is rejected with `403 Origin not allowed.` 3. **An explicit per-environment CORS allowlist** — `CORS_ORIGINS` (comma-separated; `CORS_ORIGIN` still works as a single origin). Production defaults to `https://docforum.github.io`; local dev to @@ -97,9 +96,7 @@ response body instead. See `backend/src/middleware/csrf.ts`. **Requests with no `Origin` header** (curl, server-to-server): allowed past the Origin check, but the CSRF header is still required — an attacker's page -can't win by suppressing `Origin`. Sessions issued before this change carry -no `csrf` claim in their refresh token, so they are rejected with `403` and -need one re-login. +can't win by suppressing `Origin`. Sessions issued before this change carry no `csrf` claim in their refresh token, so they are rejected with `403` and need one re-login. ### Login throttling (Phase 8, issue #35) @@ -110,15 +107,13 @@ single instance). Bucket size is capped by the first limit reached: | Phase | Response | |---|---| | Below `LOGIN_THROTTLE_BACKOFF_AFTER` failures (default 3) in the window | normal `401 { message: 'Invalid email or password.' }` | -| At/after it | `429` with a `Retry-After: ` header, growing exponentially (`LOGIN_THROTTLE_BACKOFF_BASE_SECONDS` × 2ⁿ, capped at `LOGIN_THROTTLE_BACKOFF_MAX_SECONDS`) | +| At/after it | `429` with a `Retry-After: ` header, growing exponentially (`LOGIN_THROTTLE_BACKOFF_BASE_SECONDS` × 2, capped at `LOGIN_THROTTLE_BACKOFF_MAX_SECONDS`) | | At `LOGIN_THROTTLE_LOCK_AFTER` failures (default 10) | `429` locked for `LOGIN_THROTTLE_LOCK_SECONDS` (default 15 min). The lock is set once per window and is not extended by further attempts | | Window (`LOGIN_THROTTLE_WINDOW_SECONDS`, default 15 min) | New window restarts the count at 1 and clears the lock | -A successful login clears the **per-account** counter. The per-IP bucket is -left alone, so one valid login can't reset a shared-IP budget. +A successful login clears the ** per-account** counter. The per-IP bucket is left alone, so one valid login can't reset a shared-IP budget. -Clients should honour `Retry-After` and must **not** treat a `429` as -"account locked permanently". The response is identical for a real and a +Clients should honour `Retry-After` and must **not** treat a `429` as "account locked permanently". The response is identical for a real and a non-existent address, and for a locked and a backing-off bucket — it deliberately reveals nothing about whether the account exists (an absent account also pays bcrypt's comparison cost, so timing doesn't leak either). @@ -127,12 +122,12 @@ account also pays bcrypt's comparison cost, so timing doesn't leak either). user is stuck behind: body `{ email?, ip? }` (at least one), returns `{ cleared }`. Clear **both** when a lock was taken out via the account's IP, or the IP bucket will keep refusing them. It's logged to the server console -— the `AuditEvent` table (ARCHITECTURE.md §4.10) records booking, doctor +— the `AuditEvent` table (ARCHITECTURE .md §4.10) records booking, doctor verification, payments and the referral admin actions, but not the login-throttle clear, so that's still the interim record. Under the hood this uses Express's `trust proxy` setting via the -`TRUST_PROXY` env var: `1` behind Render's single proxy hop, `false` (the +`TRUST_PROXY` env var: `1 ` behind Render's single proxy hop, `false` (the default) locally. See `ARCHITECTURE.md` §7 — the wrong value is a security bug in either direction. A cleanup interval in `src/server.ts` prunes buckets whose last failure is older than @@ -143,7 +138,7 @@ revocation list yet (see `ARCHITECTURE_ESSENTIALS.md` known gaps — JWT refresh rotation without a revocation list). Refresh tokens: httpOnly cookie, 30-day TTL, `SameSite=None; Secure` in production and `Lax`/insecure locally — the attributes live in `config.cookie` -(`backend/src/config/index.ts`) so the refresh and CSRF cookies can never +`backend/src/config/index.ts`) so the refresh and CSRF cookies can never drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. Every token carries a `tv` claim (the user's `tokenVersion` at issue time). @@ -185,6 +180,10 @@ against `tokenVersion` on every `POST /auth/refresh`. | `GET /appointments/doctor/today` | doctor | — | Caller's scheduled appointments for the current server-local calendar day, ordered by slot start time. | | `PATCH /appointments/:appointmentId/status` | doctor (treating doctor only) | `{ status: 'completed'\|'cancelled'\|'no_show' }` | Issue #3's lifecycle, and the trigger for the referral cascade (#67): if the appointment came from a referral, the referral moves in the same transaction (completed → referral completed; cancelled → referral back to `pending` and the slot released; no-show → count++, pending until `NO_SHOW_EXPIRY_THRESHOLD` (2) then `expired`). `409` unless the appointment is still `scheduled`. | | `PATCH /appointments/:appointmentId/cancel` | patient (own appointment) | — | Patient-side cancellation, `scheduled` only. Same slot-release + cascade behaviour as above. | +| `POST /appointments/:appointmentId/check-in` | patient (own appointment) | — | Records the patient's arrival time (`checkedInAt`). Only allowed within a window around the appointment start (`CHECK_IN_WINDOW_MINUTES`, default 30 before/after). `409` if already checked in or outside the window. | +| `PATCH /appointments/:appointmentId/start` | doctor (treating doctor only) | — | Marks the appointment as `in_progress` and records `startedAt`. `409` unless `scheduled` (and checked in, if check-in is required). | +| `PATCH /appointments/:appointmentId/complete` | doctor (treating doctor only) | — | Marks the appointment as `completed` and records `completedAt`; same referral cascade as the status transition above. `409` unless `in_progress`. | +| `GET /appointments/:appointmentId/queue` | patient (own appointment) or treating doctor | — | Returns `{ position, estimatedWaitMinutes, checkedInAt, startedAt }` for the caller's own appointment only — never other patients' details. Position is the count of earlier same-day appointments for that doctor still not completed; the estimate is a simple running-average delay (actual start minus scheduled start) applied to the remaining appointments ahead in the queue. | ## Consultations (`/consultations` + `/appointments`) — `backend/src/modules/consultations`