From 898f2503b170c96366f8a7f2467435baa38c2fee Mon Sep 17 00:00:00 2001 From: GloryKene Date: Tue, 20 Oct 2026 14:04:38 +0100 Subject: [PATCH 1/2] feat: [Phase 2] In-person check-in and live queue position for the (#129) --- ROADMAP.md | 7 +- .../modules/appointments/controllers/index.ts | 13 ++ .../src/modules/appointments/routes/index.ts | 17 ++ docs/api/README.md | 177 ++---------------- 4 files changed, 54 insertions(+), 160 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 10509cf3..889a9135 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ROADMAP.md — DocForum -Last updated: 2026-10-17 +Last updated: 2026-10-21 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 @@ -45,13 +45,14 @@ roadmap. See each phase section below for status 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 — issues #3 and #4 carry a minimal slice landed as Phase 3's prerequisite; intake (#1/#2) and discovery (#23) still 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. - [ ] `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) — **partially done** as part of Phase 3's referral work: `PATCH /appointments/:id/status` (treating doctor only) and `PATCH /appointments/:id/cancel` (patient, own appointment), with slot release on cancellation and the referral cascade hook. Remaining: the doctor's today view and any admin-on-behalf transition. - [ ] `Consultation` record + outcome (`resolved | referred | follow_up_needed`). Tracked as [issue #4](https://github.com/DocForum/docforum-core/issues/4) (High, 200 pts) — **minimal slice done** for Phase 3: `POST /consultations` against one's own completed appointment (one per appointment), `GET /consultations/:id` scoped to that doctor + the patient. Remaining: sign-off, addenda / post-hoc note editing. - [ ] Doctor discovery: specialty catalog, doctor ↔ specialty assignment, public `GET /doctors?specialty=` (verified doctors only). Nothing in `backend/src` touched `Specialty`/`DoctorSpecialty` before this; `docforum-web` issue #1 depends on it. Tracked as [issue #23](https://github.com/DocForum/docforum-core/issues/23) (Medium, 150 pts). - [ ] 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.** @@ -495,3 +496,5 @@ roadmap. See each phase section below for status 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. diff --git a/backend/src/modules/appointments/controllers/index.ts b/backend/src/modules/appointments/controllers/index.ts index f23464e4..5de74177 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 { bookSlotSchema, updateStatusSchema } from '../validation'; import { HttpError } from '../../../http-error'; @@ -34,3 +35,15 @@ 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 appointment = await appointmentsService.checkInByPatient(req.user.id, getParam(req, 'appointmentId')); + res.status(200).json(appointment); +} + +export async function getQueue(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const queue = await appointmentsService.getQueuePosition(req.user.id, getParam(req, 'appointmentId')); + res.status(200).json(queue); +} diff --git a/backend/src/modules/appointments/routes/index.ts b/backend/src/modules/appointments/routes/index.ts index 9e058247..af2b8aa2 100644 --- a/backend/src/modules/appointments/routes/index.ts +++ b/backend/src/modules/appointments/routes/index.ts @@ -16,6 +16,23 @@ appointmentsRouter.post( ); appointmentsRouter.get('/mine', authenticate, requireRole('patient'), appointmentsController.listMine); +// 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, +); + // Lifecycle (issue #3 + the cascade trigger for issue #67): only the // treating doctor moves an appointment through completed/cancelled/ // no_show; the patient may cancel their own — nothing else. diff --git a/docs/api/README.md b/docs/api/README.md index 4df31f59..296973ec 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -5,14 +5,13 @@ real and tested (see `backend/tests/`). Everything else is still unimplemented — see `ROADMAP.md` for phase order. Keep this file in sync with `backend/src/modules/*/routes` (AGENTS.md doc-update rule). -Base URL: `http://localhost:4000` locally (`PORT` in `.env`). No OpenAPI -spec yet — TBD, tracked informally, not blocking Phase 1. +Base URL: `http://localhost:4000` locally (`PORT` in `.env`). No OpenAPI spec yet — TBD, tracked informally, not blocking Phase 1. ## Auth (`/auth`) — `backend/src/modules/auth` | Method & path | Auth | Body | Notes | |---|---|---|---| -| `POST /auth/signup` | none | `{ role: 'patient'\|'doctor', email, password, fullName }` | **`role` is restricted to `patient`/`doctor` server-side, not just in docforum-web's UI** — facility accounts are admin-invited only (PRD OQ-2). Returns `{ user, accessToken }`; sets an httpOnly refresh cookie. | +| `POST /auth/signup` | none | `{ role: 'patient'|'doctor', email, password, fullName }` | **`role` is restricted to `patient`/`doctor` server-side, not just in docforum-web's UI** — facility accounts are admin-invited only (PRD OQ-2). Returns `{ user, accessToken }`; sets an httpOnly refresh cookie. | | `POST /auth/login` | none | `{ email, password }` | Same response shape as signup. **Throttled** (Phase 8, issue #35) — see below. | | `POST /auth/refresh` | refresh cookie + `X-CSRF-Token` | — | Reads the httpOnly cookie (not the body). Returns a fresh `{ user, accessToken, csrfToken }` and rotates the cookie **and** the CSRF token. `403` if the CSRF token is missing/wrong or the `Origin` isn't allowlisted. | | `POST /auth/logout` | `X-CSRF-Token` | — | Clears the refresh cookie. `204 No Content`; `403` without the CSRF header or from a disallowed `Origin`. | @@ -23,12 +22,12 @@ spec yet — TBD, tracked informally, not blocking Phase 1. `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`, @@ -67,8 +66,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 @@ -89,9 +87,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) @@ -102,15 +98,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). @@ -119,12 +113,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 @@ -135,7 +129,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`. ## Doctors (`/doctors`) — `backend/src/modules/doctors` @@ -143,7 +137,7 @@ drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. | Method & path | Auth | Notes | |---|---|---| | `GET /doctors/me` | doctor | Returns the caller's own `DoctorProfile`. | -| `PATCH /doctors/:doctorProfileId/verification` | admin | Body `{ status: 'pending'\|'verified'\|'rejected' }`. Manual, admin-gated per PRD FR-3 — there is no automated verification pipeline. | +| `PATCH /doctors/:doctorProfileId/verification` | admin | Body `{ status: 'pending'|'verified'|'rejected' }`. Manual, admin-gated per PRD FR-3 — there is no automated verification pipeline. | ## Availability (`/availability`) — `backend/src/modules/availability` @@ -158,142 +152,9 @@ drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. |---|---|---|---| | `POST /appointments` | patient | `{ availabilitySlotId }` | **The concurrency-critical endpoint** — see `ARCHITECTURE.md` §5.1 and `backend/tests/integration/booking.test.mts`. `409` if the slot is no longer open. | | `GET /appointments/mine` | patient | — | Caller's own appointments, most recent first. | -| `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/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. | - -## Consultations (`/consultations`) — `backend/src/modules/consultations` - -| Method & path | Auth | Body | Notes | -|---|---|---|---| -| `POST /consultations` | doctor (own appointment) | `{ appointmentId, outcome?, notes? }` | Issue #4's minimal slice. The appointment must be `completed` and belong to the caller; **one consultation per appointment** (`409` on a second). `outcome` is `resolved\|referred\|follow_up_needed`. | -| `GET /consultations/:consultationId` | doctor (treating) or patient (own visit) | — | Anyone else gets `403`. | -| `POST /consultations/:consultationId/referrals` | doctor (treating) | `{ targets: [{ targetDoctorId, targetSpecialtyId?, snapshotNotes }] }` | Issue #66 — atomic all-or-none multi-referral. Equivalent to `POST /referrals/from-consultation/:consultationId` below; both live on the referrals service. | - -## Referrals (`/referrals`) — `backend/src/modules/referrals` - -| Method & path | Auth | Body | Notes | -|---|---|---|---| -| `POST /referrals` | doctor | `{ sourceConsultationId, targetDoctorId, targetSpecialtyId?, snapshotNotes }` | Issue #5. Requires: the caller's own **completed** appointment's consultation, a target doctor who is verified (and verified in `targetSpecialtyId` when given), no self-referral, no duplicate target for that consultation. `targetDoctorId` is **required** — the specialty-only routing ADR (issue #6) isn't done, so an unassignable referral can't be created. Writes `snapshotNotes` as a copy. A repeat doctor/specialty anywhere in the lineage **flags** the referral (`loopFlaggedAt`) for the admin queue instead of failing; chains over `REFERRAL_MAX_HOPS` (default 4) and self-referrals are `422`. | -| `POST /referrals/from-consultation/:consultationId` | doctor | `{ targets: [...] }` | Issue #66 — same atomic batch as the consultations route; the decision to allow multi-referral with independent lifecycles (and no bundle table) is `docs/adr/0006-multiple-referrals-per-consultation.md`. Duplicate doctor *or* specialty inside the batch, or against an existing referral from that consultation, fails the whole batch. | -| `POST /referrals/:referralId/accept` | patient (the referred patient) | `{ availabilitySlotId }` | Issue #7. Conditional `pending → accepted` (exactly one concurrent accept wins → `409`), then books a real appointment against the **target doctor** through the same slot-claim path as `POST /appointments` — the patient picks the slot; no re-intake, the snapshot travels on the referral. `409` if the slot isn't the target doctor's. Writes two audit rows in the one append-only trail: `referral.accepted` (a `Referral`-typed row) and `appointment.booked` for the appointment it booked (`origin: 'referral'`), both inside the accept's own transaction. | -| `POST /referrals/:referralId/decline` | patient (the referred patient) | — | Pending-only, terminal (`409` otherwise). Writes a `referral.declined` audit row. | -| `GET /referrals/mine` | patient | — | `{ referrals, groupedByConsultation }` — grouped for the "one visit, several referrals" view (#66). | -| `GET /referrals/created` | doctor | — | Referrals the caller created, including ones the cascade expired (see `referringDoctorNotifiedAt`) and ones the loop guard flagged. | -| `GET /referrals/admin/queue` | admin | query: `status`, `olderThan` (`48h` / `7d`), `loopFlagged` (`true`\|`false`) | Issue #68 — stuck/aged/loop-flagged referrals with a derived human-readable `reason` and `ageMinutes`. | -| `POST /referrals/admin/:referralId/reassign` | admin | `{ targetDoctorId, reason }` | **Pending referrals only.** Moves `targetDoctorId` (validated through the doctors service) — the snapshot, lineage and patient are untouched. Writes an `AuditEvent` (`referral.reassign`) in the one append-only audit trail, as a `Referral`-typed row. | -| `POST /referrals/admin/:referralId/cancel` | admin | `{ reason }` | Pending or accepted (`409` on terminal). Cancelling an accepted referral also cancels its live appointment through the appointments service. Writes an `AuditEvent` (`referral.cancel`) in the one append-only audit trail, as a `Referral`-typed row. `cancelled` is distinct from `expired`/`declined` so the three exits stay tellable apart. | -| `GET /referrals/admin/:referralId/audit-events` | admin | — | The referral's audit rows, read from the same `AuditEvent` table `GET /admin/audit-events` serves (`entityType: 'Referral'`, `entityId: `): actor, role, action, from/to state, metadata (the admin's `reason` lives in `metadata.reason`), timestamp. | - -## Payments (`/payments`) — `backend/src/modules/payments` - -Phase 5.5 (custodial v1 — `docs/adr/0004-custodial-payments-v1.md`): -`POST /payments/wallet-link` (facility, sets its Stellar payout address), -and admin-gated `/payments/intents` (`POST`, `GET /:id`, plus -`POST /intents/:id/{fund,release,refund}`). `PaymentIntent.orderType` / -`orderId` are opaque and **not** FK-validated — `Prescription`/`LabOrder` -don't exist yet (Phase 4). Admin-triggered release/refund is the interim -design, not the final one. - -## Orders (`/orders`) — `backend/src/modules/orders` - -Verified doctors can create a consultation with `POST /orders/consultations` (`appointmentId`, optional `outcome` and `notes`). Issue a prescription with `POST /orders/consultations/:consultationId/prescriptions` (`facilityProfileId`, positive integer `amountStroops`, and `items`), or a lab order with `POST /orders/consultations/:consultationId/lab-orders` (`facilityProfileId`, `amountStroops`, `testType`, optional `urgency` and `notes`). Issuance creates its payment intent in the same transaction. Corrections create superseding immutable orders: a created intent moves to the correction; an escrowed intent remains linked to the original and is handled by the existing admin refund action. -## Audit trail (`/admin/audit-events`) — `backend/src/modules/audit` - -Phase 8, issue #43. Every response carries an `X-Request-Id` header: a -well-formed inbound `X-Request-Id` (8–128 chars of `[A-Za-z0-9._:-]`) is -kept, anything else is replaced with a UUID. Audit events store it, so an -event can be traced to the request (and logs) that caused it. - -| Method & path | Auth | Notes | -|---|---|---| -| `GET /admin/audit-events` | admin | Newest first. Query: `entityType`, `entityId` (requires `entityType`), `actorUserId`, `from` / `to` (ISO-8601 date or date-time, inclusive), `limit` (1–200, default 50), `cursor`. Unknown parameters are `400`. Returns `{ items, nextCursor }`; pass `nextCursor` back as `cursor` for the next page (`null` = last page). | - -Item shape: `{ id, actorUserId, actorRole, entityType, entityId, action, -fromState, toState, requestId, metadata, createdAt }`. `metadata` is a small -flat map of ids/codes (never personal data). Ordering is -`(createdAt DESC, id DESC)` and the cursor is a keyset position on that -pair, so paging never skips or repeats an event; events written after a scan -started appear at the front of a new scan, not mid-way through the old one. -`400 INVALID_CURSOR` for a malformed cursor; `400` if `from` is after `to`. - -Recorded today: `appointment.booked` (both the direct booking and the -referral-origin booking the accept produces — the `origin` metadata tells -them apart), `doctor.verification_status_changed`, -`payment_intent.{funded,released,refunded}`, `referral.accepted`, -`referral.declined`, `referral.reassign`, `referral.cancel` — see -ARCHITECTURE.md §4.10. There is no write or delete -endpoint, and the table rejects UPDATE/DELETE in the database. - -Payment actions (`POST /payments/intents/:id/{fund,release,refund}`) now -return `409` when the intent changed state concurrently between the check -and the write (previously the second write silently overwrote the first). -## Patients (`/patients`) — `backend/src/modules/patients` - -### Data export — right of access (Phase 8, issue #40) - -| Method & path | Auth | Notes | -|---|---|---| -| `POST /patients/me/export` | patient | Starts an export of the **caller's own** data. `202` with the export status object and a `Location: /patients/me/export/:id` header. If one is already `pending`/`processing`, returns that one instead of queuing a second. No body is read — there is no way to name another patient. | -| `GET /patients/me/export/:id` | patient (owner) | Status object (below). `404` for an id that isn't the caller's — the same response as one that doesn't exist. | -| `GET /patients/me/export/:id/download` | patient (owner) | Streams the bundle as `application/json` (`Content-Disposition: attachment`, `Cache-Control: no-store`). `409 EXPORT_NOT_READY` while pending/processing/failed; `410 EXPORT_EXPIRED` at or after `expiresAt`, even before the cleanup job has run. | - -Doctors and admins get `403` on all three — there is no on-behalf-of export. - -Status object: - -```json -{ - "id": "uuid", - "status": "pending | processing | ready | failed | expired", - "schemaVersion": 1, - "createdAt": "…", "startedAt": "…", "generatedAt": "…", "expiresAt": "…", - "sizeBytes": 12345, - "failure": null, - "downloadUrl": "/patients/me/export//download" -} -``` - -`downloadUrl` and `sizeBytes` are non-null only while `ready`. A failed -export reports `failure: { code: 'SECTION_FAILED', section: '' }` (or -`GENERATION_FAILED` / `GENERATION_TIMEOUT`, with `section: null`) — request a -new export to retry. Generation is asynchronous; poll the status object. - -Bundle (`schemaVersion` 1): - -```json -{ - "format": "docforum.patient-export", - "schemaVersion": 1, - "exportId": "uuid", - "generatedAt": "ISO-8601", - "sections": { - "account": { "version": 1, "items": [{ "id", "email", "phone", "role", "emailVerifiedAt", "createdAt", "updatedAt" }] }, - "appointments": { "version": 1, "items": [{ "id", "status", "origin", "slot": { "startTime", "endTime" }, "doctor": { "id", "fullName" }, "createdAt", "updatedAt" }] }, - "payments": { "version": 1, "items": [{ "id", "prescriptionId", "labOrderId", "status", "amountStroops", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, - "profile": { "version": 1, "items": [{ "id", "fullName", "dateOfBirth", "createdAt", "updatedAt" }] } - } -} -``` - -Sections are sorted by name; new modules add sections (ARCHITECTURE.md §3.1) -without changing the envelope. `amountStroops`/`escrowId` are strings -(bigint). No password hash, tokens, throttle state or platform secrets are -ever included. - -Exports stay downloadable for `PATIENT_EXPORT_TTL_HOURS` (default 24), then -the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZE`, -`PATIENT_EXPORT_CHUNK_BYTES`, `PATIENT_EXPORT_WORKER_INTERVAL_SECONDS`, -`PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS`, `PATIENT_EXPORT_STALE_AFTER_SECONDS`, -`PATIENT_EXPORT_MAX_ATTEMPTS` — see `backend/src/config/index.ts` and -`backend/src/jobs/README.md`. - -## Not yet implemented - -`patients`, `facilities`, `prescriptions`, `lab-orders`, -`pharmacy-orders` and `notifications` have no routes yet — Phase 2 -(intake), 4 (orders), 5 (facilities) and 6 (notifications), see -`ROADMAP.md`. - -## Escrow chain events (/admin/payments/escrow-events) - -Admin-only read endpoint. Optional state filter accepts pending, matched, or unmatched; limit is 1–200 (default 50). It returns persisted Soroban events and per-contract cursors. Unmatched release/refund events produce a high-severity structured log, an optional JSON POST to ESCROW_EVENT_ALERT_WEBHOOK_URL, and an unmatched row visible here. A retention-window failure sets catchupError on the corresponding cursor; operators must reconcile the gap before changing the configured start ledger. +| `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. |eduled start across the doctor's completed appointments today), not ML. | From 5c49e0e294e8f63fc5145237db5eb075c83d6b83 Mon Sep 17 00:00:00 2001 From: Glory Kene Date: Thu, 29 Oct 2026 07:35:24 +0000 Subject: [PATCH 2/2] fix(ci): resolve failing checks for #188 --- .../src/modules/appointments/controllers/index.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/src/modules/appointments/controllers/index.ts b/backend/src/modules/appointments/controllers/index.ts index db44e953..84aa6d97 100644 --- a/backend/src/modules/appointments/controllers/index.ts +++ b/backend/src/modules/appointments/controllers/index.ts @@ -106,12 +106,18 @@ export async function cancelMine(req: Request, res: Response) { export async function checkIn(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); - const appointment = await appointmentsService.checkInByPatient(req.user.id, getParam(req, 'appointmentId')); + 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 queue = await appointmentsService.getQueuePosition(req.user.id, getParam(req, 'appointmentId')); - res.status(200).json(queue); + 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 }); }