Skip to content
Merged
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
5 changes: 4 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions backend/src/modules/appointments/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Request, Response } from 'express';

import * as appointmentsService from '../services';
import * as patientsService from '../../patients/services';
import {
Expand Down Expand Up @@ -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 });
}
19 changes: 18 additions & 1 deletion backend/src/modules/appointments/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -85,4 +102,4 @@ appointmentsRouter.patch(
authenticate,
requireRole('patient'),
appointmentsController.cancelMine,
);
);
31 changes: 15 additions & 16 deletions docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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: <seconds>` header, growing exponentially (`LOGIN_THROTTLE_BACKOFF_BASE_SECONDS` Γ— 2ⁿ, capped at `LOGIN_THROTTLE_BACKOFF_MAX_SECONDS`) |
| At/after it | `429` with a `Retry-After: <seconds>` 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).
Expand All @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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`

Expand Down
Loading