Skip to content

feat(referrals): Phase 3 referral core — creation, accept/decline, cascade, loop guard, admin overrides - #153

Merged
afeezorobsco-cyber merged 5 commits into
DocForum:mainfrom
GdAyo19:feat/referral-phase3-cascade-multi-loop-admin
Oct 20, 2026
Merged

afeezorobsco-cyber merged 5 commits into
DocForum:mainfrom
GdAyo19:feat/referral-phase3-cascade-multi-loop-admin

Conversation

@GdAyo19

@GdAyo19 GdAyo19 commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Closes #65
Closes #66
Closes #67
Closes #68

What this does

  • Referral is real (migration 20261016214929_referral_phase3): immutable notes snapshot, required verified target doctor, lineage parentReferralId, no-show counter + referring-doctor flag, loop flags, cancelled status + reason.
  • Creation from the referring doctor's own completed appointment's consultation — POST /referrals, gates in one transaction (own consultation, no self-referral, target verified [+ in specialty], no duplicate target per consultation).
  • Multi-referral (issue [Phase 3] Multiple simultaneous referrals from one consultation (PRD §8.2.4) #66): atomic all-or-none batch (POST /referrals/from-consultation/:consultationId / POST /consultations/:id/referrals), one snapshot copy per target, independent acceptance afterwards.
  • Accept/decline (issue [Phase 3] Patient accept/decline flow + auto-generated appointment #7): conditional pending → accepted (one winner under concurrency), then a real appointment booked through the same claimOpenSlot conditional UPDATE as a direct booking — origin: 'referral', patient picks the slot, no re-intake.
  • Cascade (issue [Phase 3] Cascade rules when a referral-origin appointment is cancelled or no-shows (PRD §8.2.3) #67): appointment status transitions move the referral inside the same transaction — completed → referral completed; cancelled → back to pending with the slot released; no-show → count++, pending, then expired + referringDoctorNotifiedAt at threshold 2. Every write is conditional, so a doubled hook no-ops.
  • Loop guard (issue [Phase 3] Referral loop detection and maximum-hop limit (PRD §8.1.2) #65): bounded recursive-CTE walk; a doctor or specialty already anywhere in the chain (including the GP return) flags for the admin queue; self-referral and chains over REFERRAL_MAX_HOPS (default 4) are hard 422s.
  • Admin ops (issue [Phase 3] Admin referral operations: reassign stuck referrals and override routing #68): stuck-referral queue with derived reason/age, reassign a pending referral (target column only — snapshot untouched), cancel pending/accepted (closing a live appointment through the appointments service). Reassign/cancel write the first rows of a new append-only AuditEvent table.

Two re-scopes a reviewer should check (flagged, not absorbed)

  1. target_doctor_id is required — rather than ship a nullable column with no assignment logic, v1 makes unassigned referrals impossible. Consequence: PRD §8.2.2's distinct "waiting for a specialist" state is unreachable by construction rather than implemented. The routing ADR ([Phase 3] ADR: referral routing rule when no target doctor is specified #6) is still owed and the gap stays open.
  2. Referral.snapshotIntakeId has no column — IntakeForm doesn't exist yet ([Phase 2] IntakeForm model + structured symptom entry #1). The notes half of the snapshot rule is real and enforced; the intake half is noted as unenforceable rather than faked.

Still open, deliberately: #8 (time-based referral expiry — only the no-show cascade reaches expired today).

Testing

  • npm run typecheck clean; 42 unit + 55 integration tests pass (integration suites are self-contained: each spins up its own embedded Postgres, no pre-existing DB needed).
  • New: 8 genuinely concurrent accepts on one referral — exactly one 200, seven 409s, one appointment, seven slots left open (the same proof shape as booking.test.mts).
  • Also covered: batch atomicity/rollback, loop chains + hop-limit rejection, cascade idempotency when a transition fires twice, admin role gating, snapshot copy, accept-by-wrong-patient.

Docs (same change, per AGENTS.md)

ARCHITECTURE.md §3/§4.2–§4.4/§4.10/§5.2/§6.1.2/§6.2.2/§6.2.4, ARCHITECTURE_ESSENTIALS.md (data model map + known gaps), PRD.md §8.1.2–§8.1.4 and §8.2.1–§8.2.4, ROADMAP.md (Phase 2/3 status + changelog), docs/api/README.md (all new endpoints; also corrected its "not yet implemented" list, which still claimed payments had no routes), README.md, docs/site/repos/core.md.

@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@GdAyo19 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@GdAyo19 fix conflicts

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

CI is failing on this PR. main itself is green, so the cause is in this branch:

prisma:generate fails: The model "AuditEvent" cannot be defined because a model with that name already exists. #158 added an AuditEvent model to main, and this branch adds its own, so after you merged main the schema defines it twice. Drop your model and write referral audit events through #158's audit.record() (inside the same transaction as the change). Move any fields you need into #158's model, with a migration.

Please push a fix and CI will re-run. Before pushing, run npm run prisma:generate && npm run typecheck && npm test && npm run test:integration in backend/.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@GdAyo19

CI fails at prisma generate (P1012): The model "AuditEvent" cannot be defined because a model with that name already exists. main now has AuditEvent and the auditService.record(tx, actor, event) helper (from #158). Please drop your copy of the model and its migration, and call the shared helper inside your referral transactions instead. It also conflicts with main. Please rebase onto the latest main (or merge main into your branch), resolve the conflicts, and push.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@GdAyo19 Thanks for the PR. It can't be merged yet. To unblock it:

CI is failing. Here's what broke:

Backend (typecheck + test + build):

prisma generate: P1012 The model "AuditEvent" cannot be defined because a model with that name already exists (prisma/schema.prisma:588) — AuditEvent is already on main

Please fix these errors and run the same steps locally before pushing (npm run typecheck && npm test && npm run build, or cargo test for contracts).

Merge conflicts: this branch conflicts with main. Please rebase onto the latest main (git fetch upstream && git rebase upstream/main), resolve the conflicts (often ROADMAP.md, ARCHITECTURE*.md or prisma/schema.prisma), and force-push.

Push to this same branch and CI will re-run. Once it's green and conflict-free, it'll be reviewed for merge.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 20, 2026
@GdAyo19

GdAyo19 commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

i have fixed the conflicts please merge @afeezorobsco-cyber

GdAyo19 and others added 4 commits October 20, 2026 10:20
…scade, loop guard, admin overrides

A referral was still a Phase 0 sketch, and the two bug classes it was
most likely to get wrong — a double-booked acceptance and a patient
stuck bouncing between specialists — had no mechanism behind them at
all. Closes issues DocForum#5, DocForum#7 and DocForum#65–DocForum#68 (plus the minimal DocForum#3/DocForum#4 slices
they need: appointment status transitions and a one-per-appointment
Consultation record).

`Referral` now carries an immutable copy of the consultation notes, the
lineage parent that loop detection walks, and the no-show/loop/admin
bookkeeping; `Appointment.availabilitySlotId` became nullable so a
cancellation can release its slot without erasing history; and
`AuditEvent` is a new append-only table for the admin reassign/cancel
actions. Accept books a real appointment through the same claimOpenSlot
conditional UPDATE as a direct booking — proven by 8 genuinely
concurrent accepts, exactly one of which wins — and appointment status
transitions cascade back into the referral inside the same transaction,
so a referral can never be left accepted behind a dead appointment.
Repeat doctors or specialties anywhere in a chain are flagged for the
admin queue rather than silently allowed; chains over the hop limit are
hard rejections.

Two deliberate re-scopes for review, not silent defaults: a concrete
verified target_doctor_id is required, so the routing gap (DocForum#6) is closed
for v1 by making unassigned referrals impossible — which also makes
PRD §8.2.2's distinct "waiting for a specialist" state unreachable
rather than implemented, and the ADR is still owed; and
snapshotIntakeId has no column because IntakeForm doesn't exist yet
(DocForum#1), so the intake half of the snapshot rule is noted as unenforceable
rather than faked. Time-based referral expiry (DocForum#8) is likewise still
open — only the no-show path reaches expired.

Docs reconciled in the same change (ARCHITECTURE §4.2–§4.4/§4.10/§5.2/§6,
ARCHITECTURE_ESSENTIALS, PRD §8.1.2–§8.1.4 and §8.2.1–§8.2.4, ROADMAP
Phase 2/3 + changelog, docs/api/README.md, README, docs site). Tests:
42 unit + 55 integration against real embedded Postgres.
… PR closes-keywords ready

decision, which the implementation had already made (independent
lifecycles, atomic all-or-none creation, grouped only for the patient
view, no bundle table) but never written down. Adds
docs/adr/0006-multiple-referrals-per-consultation.md and points
ARCHITECTURE §5.2, PRD §8.2.4, ROADMAP and docs/api at it. Also draws
out the referral status machine in ARCHITECTURE §4.4 — DocForum#67's
state-diagram DoD — with the conditional-UPDATE rule that makes the
cascade idempotent.
The Phase 3 referral branch added its own `AuditEvent` model (referral-shaped:
actorRole as a string, a referralId FK, a free-text reason column). Upstream's
issue DocForum#43 work added the canonical, append-only `AuditEvent` (entityType /
entityId / fromState / toState / requestId, DB triggers rejecting mutation).
Having both in one schema is invalid Prisma (P1012: duplicate model) and would
have meant two audit trails with different guarantees.

Resolution: keep upstream's single AuditEvent table and record the referral
admin actions in it as `entityType: 'Referral'` rows, written through the
audit module's `record()` inside the transaction of the referral change — so
the append-only triggers cover them too, and `GET /referrals/admin/:id/audit-events`
reads the same rows `GET /admin/audit-events` serves.

- schema: drop the duplicate model and the `Referral.auditEvents` relation.
- migration: the referral migration no longer creates the table (upstream's
  20261017212335_add_audit_event owns it) and no longer adds
  `Consultation.appointmentId` (upstream's 20261020000000_order_payment_intents
  owns that column, its unique index and its FK — adding it twice made
  `prisma migrate deploy` fail on a duplicate column).
- referrals service: admin reassign/cancel now call `auditService.record()`
  and the trail is read through the audit module's service, so the referrals
  module never touches the audit table directly (module-boundary hard rule).
- reason: bounded to 200 chars (the audit metadata string limit) and carried
  in `metadata.reason` — the shared table has no free-text reason column.
- appointments: the export section tolerates the now-nullable
  `availabilitySlotId` (a cancelled appointment releases its slot).

Co-Authored-By: OpenCode <opencode@opencode.ai>
…th main

- ARCHITECTURE.md §3/§4.4/§4.10 + ARCHITECTURE_ESSENTIALS.md: one audit
  trail — the referral admin actions are 'Referral'-typed rows in the
  Phase 8 AuditEvent table, not a second table.
- ARCHITECTURE.md §4 intro: the referral migration owns the Referral
  columns; the AuditEvent table is upstream's migration.
- docs/api/README.md: drop the duplicated 'Audit trail' section left by
  the merge, document the referral audit rows and the recorded actions,
  and fix a mojibake range in the escrow-events section.
- ROADMAP.md: the audit-wiring status, the referral branch's entry, and
  a note on the rebase.
- integration tests: the referral suites move to ports 5445-5447 — main
  already uses 5433-5442, and vitest runs the files in parallel, so the
  old 5440-5442 collided with payments-bigint, audit and patient-export.

Co-Authored-By: OpenCode <opencode@opencode.ai>
@GdAyo19
GdAyo19 force-pushed the feat/referral-phase3-cascade-multi-loop-admin branch from 6b96dde to 8b534d3 Compare October 20, 2026 10:51
…produces

The referral accept path created an Appointment through bookForReferral
without writing an audit row, so it was the one way to create an
appointment that left no trail at all — and the existing
`appointment.booked` event's `origin` metadata (always `direct` until
now) was clearly built to tell the two booking paths apart.

- appointments: `bookForReferral` now writes the same `appointment.booked`
  event a direct booking writes, in the caller's transaction, with
  `origin: 'referral'` and the referral it came from.
- referrals: the patient's own state change is recorded as
  `referral.accepted` / `referral.declined`, in the same transaction as
  the accept — so the decision and the appointment it produced are
  recorded together or not at all.
- `declineReferral` now runs in a transaction; it previously used the
  root client, which `auditService.record()` refuses by design.
- The controller builds the actor with `auditActor(req)`, like the admin
  referral routes already did.

Still unaudited, and recorded as an open gap in ARCHITECTURE.md §4.10
rather than silently dropped: the referral's creation (`null → pending`)
and the two transitions the appointment cascade drives (`accepted →
completed`, `accepted → expired`) — the cascade fires from inside the
appointment's status transition, which has no actor of its own to
attribute. Both are owed when Phase 4/6 wire the remaining state
changes.

Tests: two new integration cases asserting the audit rows against the
real AuditEvent table — the accept's two events (the referral transition
and the appointment it booked) and the decline's one.

Co-Authored-By: OpenCode <opencode@opencode.ai>

@afeezorobsco-cyber afeezorobsco-cyber left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed: no malicious code found, CI is green. Merging.

@afeezorobsco-cyber
afeezorobsco-cyber merged commit 4887aa4 into DocForum:main Oct 20, 2026
2 checks passed
afeezorobsco-cyber added a commit that referenced this pull request Oct 20, 2026
#153 added a repo-relative link (docs/adr/0006-...md) to ROADMAP.md. The
docs sync copies ROADMAP.md into the VitePress site verbatim, where that
path has no page, so vitepress build failed its dead-link check and the
docs deploy on main broke. Remote files already get their relative links
rewritten to GitHub URLs; apply the same rewrite to local files.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-rebase Has merge conflicts with main; the author needs to rebase

Projects

None yet

2 participants