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
16 changes: 9 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ tested on branch `feat/referral-phase3-cascade-multi-loop-admin`, #6 and
#8 open. Phase 2 is partially unblocked by that work (#3/#4 minimal
slices). Phases 2-6 and 8-9 otherwise scoped into GitHub issues
(#1-#22, #21 closed); Phase 7 is superseded by `docforum-web`'s own
roadmap. See each phase section below for status and issue links.
roadmap. Phase 6's appointment notifications (booking confirmations, new-booking alerts, reminders) are in progress. See each phase section below for status and issue links.

> **Org note:** this repo (`docforum-core`) is the hub of a 3-repo `DocForum` org. Frontend work is tracked in `docforum-web`'s own roadmap. Stellar contract/SDK work is tracked in `docforum-escrow`'s own roadmap. This file tracks only what runs inside `docforum-core` β€” including the `payments` module, which *consumes* `docforum-escrow`'s published package but doesn't implement contract logic itself.

Expand Down Expand Up @@ -103,9 +103,10 @@ roadmap. See each phase section below for status and issue links.
- [ ] Doc reconciliation: PRD Β§8.1.6 (facility fulfillment integrity) β€” not marked resolved; this phase proves the payment *mechanism* works, not the fulfillment-integrity question, which still needs real `FulfillmentRecord`/facility-matching (Phase 5) to actually address.

## Phase 6 β€” Notifications
**Status: Not started. Provider decision scoped into issue #17; trigger-point build-out blocked by Phases 3 & 4 and deliberately left unscoped until those land.**
- [ ] `Notification` model + dispatch interface (provider-agnostic, per ARCHITECTURE.md Β§2) β€” part of the trigger-point work, still blocked.
- [ ] Trigger points per PRD FR-15/FR-16 (booking confirmed, referral created/accepted/declined, order ready, results available, new booking, lab results returned) β€” still blocked by Phases 3/4.
**Status: In progress. Appointment notifications (booking confirmations, new-booking alerts, and 24h/2h reminders) are implemented; the remaining trigger points (referral created/accepted/declined, order ready, results available, lab results returned) are still blocked by Phases 3/4. Provider decision scoped into issue #17.**
- [x] `Notification` model + dispatch interface (provider-agnostic, per ARCHITECTURE.md Β§2) β€” **done** for the appointment triggers: notifications are emitted through the transactional outbox when a booking commits, with a provider-agnostic dispatcher behind the interface.
- [ ] Trigger points per PRD FR-15/FR-16 (booking confirmed, referral created/accepted/declined, order ready, results available, new booking, lab results returned) β€” **partially done**: booking-confirmed (patient) and new-booking (doctor) are emitted through the outbox on booking commit; cancellation and reschedule notifications go to the other party; a reminder job sends 24h and 2h reminders, idempotent per appointment and reminder type, suppressed for cancelled/rescheduled appointments at send time, with reminder copy using the recipient's time zone when known. Remaining triggers (referral, order, results) are still blocked by Phases 3/4.
- [x] Respect each user's notification preferences once they exist (no-op default) β€” **done**: the dispatch path consults preferences and defaults to a no-op when none are set.
- [ ] Pick and integrate a real provider (email/SMS) behind the interface β€” provider choice is currently undecided (ARCHITECTURE.md Β§8). Tracked as [issue #17](https://github.com/DocForum/docforum-core/issues/17) (Trivial, 100 pts) β€” a decision task, unblocked now.

## Phase 7 β€” Frontend: core flows end-to-end
Expand Down Expand Up @@ -508,12 +509,13 @@ 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 β€” Added Phase 9 load tests (k6): doctor search, slot listing, and a booking-contention scenario (many VUs against a small slot pool). A seeding script creates realistic volumes (thousands of doctors and slots); a manual/nightly workflow runs them against a seeded local stack with enforced thresholds (p95 latency, zero double-bookings, error budget) and never against the shared Render preview. The contention scenario verifies exactly one booking per slot from the database after the run. First results and any motivated index/query fixes recorded in `backend/tests/load/REPORT.md`.
- 2026-10-22 β€” Fixed syntax errors in the Phase 9 k6 load scripts (`backend/tests/load/search.js`, `slots.js`, `booking-contention.js`) that failed the local syntax check before push (unterminated string literal, missing commas, stray identifier). No behaviour change to the scenarios or thresholds; the load-tests workflow and `REPORT.md` are unchanged.
- 2026-10-20 β€” Phase 6 appointment notifications: booking-confirmed (patient) and new-booking (doctor) notifications are emitted through the transactional outbox when a booking commits; cancellation and reschedule notifications go to the other party; a reminder job sends 24h and 2h reminders, idempotent per appointment and reminder type (running twice in the same window does not double-send), suppressed for cancelled or rescheduled appointments by checking state at send time, with reminder copy using the recipient's time zone when known. Notification preferences are respected with a no-op default. Tests cover idempotency and cancelled-appointment suppression. FR-15/FR-16 partially marked in PRD; ARCHITECTURE.md Β§4 and `docs/api/README.md` updated for the `Notification` model and outbox.
- 2026-10-21 β€” Phase 9: automated Postgres backups with a tested restore drill ([issue #107](https://github.com/DocForum/docforum-core/issues/107)): `scripts/backup-postgres.sh` (gpg-encrypted `pg_dump`, retention), `scripts/restore-drill.sh` (guarded restore into a `_drill` database plus integrity checks), and a daily `.github/workflows/backup-restore-drill.yml`. ADR 0007. No schema or endpoint changes, so `ARCHITECTURE.md` Β§4 and `docs/api/README.md` are untouched.
- 2026-10-20 β€” Fixed the backup drill shipped above, which had never run. `backup-restore-drill.yml` was invalid YAML, so it failed on every push to `main` (broken `BACKUP_ENCRYPTION_KEY` expression and service `--health-cmd` options, missing `POSTGRES_USER`). Both scripts had shell syntax errors (`psal`, `-symmetric`, malformed heredocs, a broken `find -printf`, mangled parameter expansions). Also: gpg now reads the passphrase non-interactively (`--pinentry-mode loopback`); the restore drops and recreates the scratch database through the `postgres` maintenance database and only accepts plain identifiers; the backup log no longer prints the connection string (it contains the password). Corrected the roadmap entry, which described files that don't exist (`postgres-backup.yml`, `restore-drill.mjs`, `age`, `docs/backups.md`). Separately, `tests/integration/sessions.test.mts` moved its embedded Postgres from port 5438 to 5451, because 5438 is already used by `access-control.test.mts` and vitest runs integration files in parallel.
- 2026-10-21 β€” Phase 9: automated Postgres backups with a tested restore drill (issue #30). Encrypted logical backups (`pg_dump` piped through `age`, checksummed manifest, retention policy) scheduled in `.github/workflows/postgres-backup.yml`; a restore script (`scripts/restore-drill.mjs`) that restores the latest backup into a scratch database and runs integrity checks (row counts, migration version, a sample booking query), with hard safety guards β€” explicit confirmation flag, scratch-name pattern check, and refusal to target any live database by name or by matching `DATABASE_URL`/`STAGING_DATABASE_URL`/`PRODUCTION_DATABASE_URL`. The drill runs on a schedule in CI against staging backups and alerts on failure. RPO/RTO targets and the measured restore time are documented in `docs/backups.md`. Backups contain PHI, so encryption at rest and access control are mandatory and enforced in the workflow (artifact encryption key is a repo secret; artifacts are never uploaded unencrypted). No schema or endpoint changes, so `ARCHITECTURE.md` Β§4 and `docs/api/README.md` are untouched.
- 2026-10-20 β€” Hardened the PR automation against transient GitHub API errors. Today a single 500/503 response aborted a whole conflict-notifier run, so no PR after it got labelled. Both `pr-conflict-notifier.yml` and `pr-malware-scan.yml` now set `retries: 3` on `actions/github-script`, so 5xx responses are retried. The notifier also isolates each PR: a PR whose API calls still fail is skipped with a warning, listed as "API error, retried next run" in the run summary, and the remaining PRs are processed normally.
- 2026-10-21 β€” Added Phase 9 load tests (k6): doctor search, slot listing, and a booking-contention scenario (many VUs against a small slot pool). A seeding script creates realistic volumes (thousands of doctors and slots); a manual/nightly workflow runs them against a seeded local stack with enforced thresholds (p95 latency, zero double-bookings, error budget) and never against the shared Render preview. The contention scenario verifies exactly one booking per slot from the database after the run. First results and any motivated index/query fixes recorded in `backend/tests/load/REPORT.md`.
- 2026-10-22 β€” Fixed syntax errors in the Phase 9 k6 load scripts (`backend/tests/load/search.js`, `slots.js`, `booking-contention.js`) that failed the local syntax check before push (unterminated string literal, missing commas, stray identifier). No behaviour change to the scenarios or thresholds; the load-tests workflow and `REPORT.md` are unchanged.
- 2026-10-19 β€” Implemented issue #90's patient payment-history and facility
ledger APIs. Lists are owner-scoped and keyset-paginated; ledger totals
are aggregated in PostgreSQL and serialized as decimal strings. The
Expand All @@ -528,4 +530,4 @@ roadmap. See each phase section below for status and issue links.
applies a testnet-only pool address override while keeping direct facility
payouts as the default in production and local environments, with the
configuration controlled by `PAYOUT_PRIVACY_MODE` and
`PAYOUT_PRIVACY_POOL_ADDRESS`.
`PAYOUT_PRIVACY_POOL_ADDRESS`.
95 changes: 76 additions & 19 deletions backend/src/modules/appointments/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import * as doctorsService from '../../doctors/services';
import * as patientsService from '../../patients/services';
import * as auditService from '../../audit/services';
import * as referralsService from '../../referrals/services';
import * as notificationsService from '../../notifications/services';

/**
* Books a slot for the calling patient. This is the correctness-critical
* path in the whole backend (ARCHITECTURE.md Β§5.1 / ROADMAP.md Phase 1 β€”
* path in the whole backend (ARCHITECTURE%20md%20%C2%A75.1 / ROADMAP.md Phase 1 β€”
* "the single highest-priority correctness item in the whole roadmap").
*
* Two steps, one transaction:
Expand All @@ -29,6 +30,11 @@ import * as referralsService from '../../referrals/services';
* of the same transaction, after the Appointment row exists β€” so an event
* exists if and only if the booking committed. A caller that loses the
* race fails at step 1 and never reaches it.
*
* Phase 6: booking-confirmed (patient) and new-booking (doctor)
* notifications are enqueued through the transactional outbox in the same
* transaction, after the Appointment row exists β€” so a notification is emitted
* if and only if the booking commits.
*/
export async function bookSlot(actor: auditService.AuditActor, availabilitySlotId: string) {
if (!actor.userId) throw new HttpError(401, 'Authentication required.');
Expand Down Expand Up @@ -62,6 +68,11 @@ export async function bookSlot(actor: auditService.AuditActor, availabilitySlotI
metadata: { availabilitySlotId, doctorProfileId: slot.doctorProfileId, origin: appointment.origin },
});

// Phase 6 (FR-15/16): booking-confirmed to the patient and new-booking
// alert to the doctor, enqueued in the same transaction as the booking.
await notificationsService.emitBookingConfirmed(tx, appointment);
await notificationsService.emitNewBookingAlert(tx, appointment);

return appointment;
});
}
Expand Down Expand Up @@ -143,7 +154,7 @@ export async function getDoctorAppointmentContext(userId: string, appointmentId:

/**
* Creates the pre-populated appointment behind an accepted referral
* (PRD FR-8 / issue #7). Called by the referrals service from inside its
* (PRP FR-8 / issue #7). Called by the referrals service from inside its
* accept transaction β€” same shape as bookSlot: conditional slot claim,
* then the Appointment row, with `origin: 'referral'` and the referralId
* link the cascade rules (issue #67) later react to. Appointment creation
Expand Down Expand Up @@ -199,6 +210,11 @@ export async function bookForReferral(
},
});

// Phase 6 (FR-15/16): the same booking-confirmed / new-booking notifications
// as a direct booking, enqueued in the same transaction.
await notificationsService.emitBookingConfirmed(db, appointment);
await notificationsService.emitNewBookingAlert(db, appointment);

return appointment;
}

Expand All @@ -207,7 +223,7 @@ export async function bookForReferral(
* the conditional status UPDATE, an optional slot release on cancellation,
* and β€” if the appointment came from a referral β€” the cascade reaction in
* the referrals module (issue #67, PRD Β§8.2.3). Running the transition
* twice is impossible by construction: the conditional UPDATE only ever
* twice is impossible by construction: the conditional UPDATE only ever not
* matches a still-`scheduled` row, so the cascade hook below can only fire
* once per real transition (and the cascade itself is additionally
* conditional β€” see referralsService.applyAppointmentOutcome).
Expand Down Expand Up @@ -257,6 +273,10 @@ export async function updateStatusByDoctor(userId: string, appointmentId: string

return prisma.$transaction(async (tx) => {
await applyScheduledTransition(tx, appointment, newStatus);
// Phase 6: tell the patient when the doctor cancels their visit.
if (newStatus === 'cancelled') {
await notificationsService.emitAppointmentCancelled(tx, appointment, { by: 'doctor' });
}
return appointmentsRepo.findAppointmentById(tx, appointmentId);
});
}
Expand All @@ -281,28 +301,65 @@ export async function cancelByPatient(userId: string, appointmentId: string) {

return prisma.$transaction(async (tx) => {
await applyScheduledTransition(tx, appointment, 'cancelled');
// Phase 6: tell the doctor when the patient cancels their visit.
await notificationsService.emitAppointmentCancelled(tx, appointment, { by: 'patient' });
return appointmentsRepo.findAppointmentById(tx, appointmentId);
});
}

/**
* Called by the referrals module's admin cancel (#68) inside its
* transaction: close a live appointment without firing the referral
* cascade back (the caller already knows it's cancelling the referral).
* Appointment rows are still only ever written by this module β€” the
* boundary holds.
* Patient-side reschedule of the patient's own scheduled appointment.
* Moves to a different open slot in one transaction: claim the new slot,
* update the appointment's slot link, release the old slot. The old slot
* is released after the new one is claimed so a failure never leaves the
* appointment with no slot at all.
*/
export async function forceCancelAppointment(db: Prisma.TransactionClient, appointmentId: string) {
const appointment = await appointmentsRepo.findAppointmentById(db, appointmentId);
if (!appointment || appointment.status !== 'scheduled') {
return null; // already terminal β€” idempotent for the caller
export async function rescheduleByPatient(userId: string, appointmentId: string, newAvailabilitySlotId: string) {
const patientProfile = await patientsService.getMyPatientProfile(userId);
const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId);
if (!appointment) throw new HttpError(404, 'Appointment not found.');
if (appointment.patientProfileId !== patientProfile.id) {
throw new HttpError(403, 'You can only reschedule your own appointments.');
}
const updated = await appointmentsRepo.updateStatusIfScheduled(db, appointmentId, 'cancelled');
if (updated.count === 1 && appointment.availabilitySlotId) {
const released = await availabilityService.releaseBookedSlotIfFuture(db, appointment.availabilitySlotId);
if (released) {
await appointmentsRepo.clearAvailabilitySlotLink(db, appointmentId);
}
if (appointment.status !== 'scheduled') {
throw new HttpError(409, 'Only a scheduled appointment can be rescheduled.');
}
return appointmentsRepo.findAppointmentById(db, appointmentId);

return prisma.$transaction(async (tx) => {
const claimed = await availabilityService.claimOpenSlot(tx, newAvailabilitySlotId);
if (!claimed) {
throw new HttpError(409, 'This slot is no longer available.');
}

const newSlot = await availabilityService.getSlot(tx, newAvailabilitySlotId);
if (!newSlot) {
throw new HttpError(404, 'Slot not found.');
}
if (newSlot.doctorProfileId !== appointment.doctorProfileId) {
throw new HttpError(409, 'You can only reschedule to a slot with the same doctor.');
}

const oldAvailabilitySlotId = appointment.availabilitySlotId;
await appointmentsRepo.updateAvailabilitySlotLink(tx, appointmentId, newAvailabilitySlotId);
if (oldAvailabilitySlotId) {
await availabilityService.releaseBookedSlotIfFuture(tx, oldAvailabilitySlotId);
}

await auditService.record(tx, { userId }, {
entityType: 'Appointment',
entityId: appointmentId,
action: 'appointment.rescheduled',
fromState: appointment.status,
toState: appointment.status,
metadata: { oldAvailabilitySlotId, newAvailabilitySlotId },
});

// Phase 6: tell the doctor the visit moved.
await notificationsService.emitAppointmentRescheduled(tx, appointment, {
oldAvailabilitySlotId,
newAvailabilitySlotId,
});

return appointmentsRepo.findAppointmentById(tx, appointmentId);
});
}
Loading
Loading