medOS ultra

Transfer Request System

TransferRequest lifecycle replacing one-shot transfer: REQUESTED to ACCEPTED to COMPLETED, queue placement, compensation path, policy gates.

18 min read diagramsUpdated 2026-06-14docs/architecture/transfer-request-system.md

Unified design for a TransferRequest entity that replaces the current one-shot PUT /encounters/:id/transfer endpoint with a proper request lifecycle, ack flow, queue placement, and read-model projection.

Status: Partial — backend + read-model + orchestrator SHIPPED; frontend worklist wiring + Transfer Navigator miniapp still open. Created 2026-05-26; status updated 2026-06-14. Replaces: Direct encounter sub-clinic flip in encounter.service.ts:1116.

✅ Shipped:

🚧 Still open:

  • No registered Transfer miniapp — there is no DynamicCoreApp.TRANSFER enum entry, renderer case, or app-picker entry. (See the Transfer Navigator miniapp plan in memory.)
  • The worklist transfer-in / transfer-out tabs (medical-kit central-worklist and medical-record TabPatientTransfer + PatientTransferIn / PatientTransferOut workflows) are still MOCK data — not yet wired to the transfer_requests read model.

0. TL;DR

Today Proposed
PUT /encounters/:id/transfer flips sub-clinic in one shot POST /transferRequests creates a lifecycle entity
Source encounter closed (ENTERED_IN_ERROR), new one created (PLANNED) immediately Encounter flip happens only on ACCEPTED state
Emits clinical.encounter.transferred Moleculer event — orchestrator does not consume it Emits TRANSFER_REQUESTED / _ACCEPTED / _COMPLETED to hospital_events
Receiving sub-clinic has no signal acknowledgement_requests + department_queues (incoming-transfers tab)
No cancel-in-flight, no compensation if creation fails Cancellable until IN_PROGRESS; compensation reopens source encounter
No structured reason / urgency / audit trail Reason enum, urgency tier, full audit
PatientTransfer entity is porter dispatch, unrelated TransferRequest can spawn a PatientTransfer (porter job) once ACCEPTED

1. Why this exists

1.1 Problems with the current flow

encounter.service.ts:1116 transferEncounter:

PUT /v2/administration/encounters/:id/transfer { subClinicId, remark }
  └─ update encounter.status = ENTERED_IN_ERROR        ── DESTRUCTIVE
  └─ create new encounter.status = PLANNED on toSubClinic
  └─ ctx.emit('clinical.encounter.transferred', ...)   ── orphan event
  1. Irreversible mid-failure. If the new-encounter creation throws after the source is closed, the patient is in limbo.
  2. No acknowledgment. Receiving sub-clinic’s charge nurse has no signal until the patient walks in.
  3. No structured reason. Only remark: string. Quality teams can’t aggregate.
  4. No orchestrator projection. The Moleculer event clinical.encounter.transferred is consumed by webhook dispatcher only — encounter_journey_cache, department_queues, and acknowledgement_requests never update. Grep confirms: no handleEncounterTransferred in encounter-orchestrator/index.ts.
  5. No cancel-in-flight, no rejection. Once posted, the move is done.
  6. The PatientTransfer entity is something else. It’s the porter/stretcher dispatch with TransferStatus.DRAFT → PENDING → TRANSFERRING → COMPLETED and assigned staff + checkpoints. Different concern.

1.2 Pattern this codebase uses for “X is requested → ack → accept → complete”

Reference implementations:

Mongo entity (write truth)
  ├─ status enum: REQUESTED → ACKNOWLEDGED → ACCEPTED → IN_PROGRESS → COMPLETED
  ├─ ctx.emit('clinical.X.created' / '.updated')           ── webhook dispatcher
  └─ supabase.from('hospital_events').insert(...)          ── orchestrator bus
                         │
                         ▼
            encounter-orchestrator (Deno edge fn)
                         │
                ┌────────┼────────┐
                ▼        ▼        ▼
    encounter_journey_   department_   acknowledgement_
        cache             queues          requests
   (current location)  (receiving dept)  (charge nurse)

This document specifies how transferRequest slots into that pattern.


2. Entity Contracts

2.1 TransferRequest (MongoDB — write truth)

Location: packages/platform-api-schema/src/administration/transferRequest/ Service: services/administration/src/api/administration/modules/transferRequest/

Field Type Notes
_id UUID MongoDB doc id
encounterId UUID FK → Encounter (source)
patientRef UUID FK → Patient (denormalized for queries)
fromClinicId UUID Source clinic
fromSubClinicId UUID Source sub-clinic
toClinicId UUID Target clinic
toSubClinicId UUID Target sub-clinic (required)
toBedId UUID? Optional — for IPD-to-IPD ward transfers
reason TransferReason enum See §3.2
reasonDetail string? Freetext supplement to enum
urgency TransferUrgency enum ROUTINE / URGENT / STAT
status TransferRequestStatus enum See §3.1
requestedBy UUID User who created
requestedAt Date
acknowledgedBy UUID? Receiving charge nurse
acknowledgedAt Date?
acceptedBy UUID? Receiving doctor / charge nurse
acceptedAt Date? Encounter flip happens here
inProgressAt Date? Porter dispatched (or self-transport started)
completedAt Date? Patient arrived
rejectedBy UUID?
rejectedAt Date?
rejectReason string?
cancelledBy UUID?
cancelledAt Date?
cancelReason string?
linkedPatientTransferId UUID? If a porter job was spawned (see §6)
linkedNewEncounterId UUID? Set after ACCEPTED — the new encounter created on toSubClinicId
metadata JSONB Free-form (ESI level, ward, equipment, isolation, etc.)
createdAt, updatedAt Date EntityBase

Mongo collection: transfer_request

2.2 What’s reused, not recreated

Reused entity How
Encounter The source encounter stays open until ACCEPTED. New encounter is created on accept.
PatientTransfer (porter dispatch) Optionally spawned on inProgress if the move needs a stretcher/porter. transferRequest.linkedPatientTransferId links back.
AcknowledgementRequest Spawned on REQUESTED for the receiving sub-clinic charge nurse. Lifecycle from acknowledgement-system.md.

2.3 What stays in transfer_request vs. moves to read model

Read model What lives here
encounter_journey_cache.transfer_context (new JSONB column) Latest open transfer for the encounter — denormalized for UI banner / sticky alert
department_queues (incoming-transfers row) One row per pending transfer, dept_type=transfer-incoming, location_id=toSubClinicId
acknowledgement_requests One row per request — pending ack on receiving side
transfer_request (Mongo) Full audit trail, all state transitions

3. Status Enums & Progressions

3.1 TransferRequestStatus

                     ┌─────────────┐
                     │  REQUESTED  │  source dept posts the ask
                     └──────┬──────┘
                            │
              ┌─────────────┼─────────────┐
              ▼             ▼             ▼
       ┌─────────────┐ ┌──────────┐ ┌──────────┐
       │ACKNOWLEDGED │ │ REJECTED │ │CANCELLED │
       │ (recv nurse │ │(recv-side│ │(src-side │
       │   saw it)   │ │  decline)│ │ withdraw)│
       └──────┬──────┘ └──────────┘ └──────────┘
              │
              ▼
       ┌─────────────┐
       │  ACCEPTED   │  ◀── ENCOUNTER FLIP happens here
       │ (recv dept  │      (source closed, target encounter created)
       │   commits)  │
       └──────┬──────┘
              │
              ▼
       ┌─────────────┐
       │ IN_PROGRESS │  porter dispatched / patient walking
       └──────┬──────┘
              │
              ▼
       ┌─────────────┐
       │  COMPLETED  │  patient arrived at target
       └─────────────┘

Enum values: REQUESTED, ACKNOWLEDGED, ACCEPTED, IN_PROGRESS, COMPLETED, REJECTED, CANCELLED

Reversibility rules:

From To Allowed? Compensation
REQUESTED CANCELLED none
ACKNOWLEDGED CANCELLED none
ACKNOWLEDGED REJECTED none
ACCEPTED CANCELLED Reopen source encounter, close target encounter
IN_PROGRESS CANCELLED ✓ (with reason required) Same as above + cancel PatientTransfer if spawned
COMPLETED anything terminal

3.2 TransferReason

WARD_FULL, SPECIALTY_REQUIRED, ESCALATION, DOWNGRADE, OT_TRANSFER, BED_REASSIGN, PATIENT_REQUEST, INFECTION_CONTROL, OTHER

3.3 TransferUrgency

ROUTINE, URGENT, STAT


4. REST API

All under services/administrationRESOURCE_NAME = 'transferRequest'.

Verb Path DTO Purpose
POST /transferRequests CreateTransferRequestDto Source dept creates the ask
GET /transferRequests/:_id Detail
GET /transferRequests ListTransferRequestsDto List w/ filters (status, fromSubClinic, toSubClinic, encounter)
GET /transferRequests/incoming/:subClinicId Receiving dept’s worklist tab
PUT /transferRequests/:_id/acknowledge { acknowledgedBy } Recv nurse marks seen
PUT /transferRequests/:_id/accept AcceptTransferRequestDto Recv dept commits — triggers encounter flip
PUT /transferRequests/:_id/reject { rejectReason } Recv dept declines
PUT /transferRequests/:_id/start Move starts (porter / self-walk) → IN_PROGRESS
PUT /transferRequests/:_id/complete Patient arrived
PUT /transferRequests/:_id/cancel { cancelReason } Source dept withdraws (any pre-COMPLETED state)

5. Moleculer Events & hospital_events Emissions

5.1 Moleculer (existing webhook dispatcher)

Action Event
create clinical.transferRequest.created
acknowledge clinical.transferRequest.acknowledged
accept clinical.transferRequest.accepted
reject clinical.transferRequest.rejected
start clinical.transferRequest.started
complete clinical.transferRequest.completed
cancel clinical.transferRequest.cancelled

5.2 hospital_events rows (new — orchestrator bus)

Trigger event_type payload
create TRANSFER_REQUESTED { transferRequestId, fromSubClinicId, toSubClinicId, reason, urgency, requestedBy, requestedAt }
acknowledge TRANSFER_ACKNOWLEDGED { transferRequestId, acknowledgedBy, acknowledgedAt }
accept TRANSFER_ACCEPTED { transferRequestId, acceptedBy, acceptedAt, newEncounterId }
reject TRANSFER_REJECTED { transferRequestId, rejectedBy, rejectReason }
start TRANSFER_IN_PROGRESS { transferRequestId, startedBy, linkedPatientTransferId? }
complete TRANSFER_COMPLETED { transferRequestId, completedAt, completedBy }
cancel TRANSFER_CANCELLED { transferRequestId, cancelledBy, cancelReason, wasAccepted: boolean }

All rows include encounter_id and patient_id columns (top-level on hospital_events).


6. Encounter-Orchestrator Handler

New handler: handleTransferRequest(evt) in encounter-orchestrator/index.ts.

Dispatched from the central event router based on event_type prefix TRANSFER_*.

6.1 Side-effect table

event_type encounter_journey_cache department_queues acknowledgement_requests Mongo side effect
TRANSFER_REQUESTED transfer_context = { status:'requested', toSubClinicId, urgency, reason, requestedAt } on source journey INSERT row dept_type='transfer-incoming' location_id=toSubClinicId ticket_id=transferRequestId status='WAITING' priority=urgency INSERT subject_order_type='encounter' subject_order_id=transferRequestId recipient_type='department' recipient_department_id=toSubClinicId priority=urgency none
TRANSFER_ACKNOWLEDGED update transfer_context.status='acknowledged' update queue row status → ACKNOWLEDGED update ack row status → acknowledged, set response_user_id none
TRANSFER_ACCEPTED update transfer_context.status='accepted', acceptedAt, newEncounterId; on new encounter’s journey: set current_clinic_id, current_sub_clinic_id, transfer_context.linkedSourceEncounterId queue row status → ACCEPTED; spawn new dept_queues row in target if not exists resolve ack row (if pending) Encounter flip via Mongo (see §6.2)
TRANSFER_REJECTED update transfer_context.status='rejected'; close transfer_context after 24h via cron DELETE queue row resolve ack row → declined none
TRANSFER_IN_PROGRESS transfer_context.status='in_progress' queue row → IN_PROGRESS If payload.linkedPatientTransferId is null and reason ∈ {ESCALATION, BED_REASSIGN}, optionally auto-spawn PatientTransfer (porter job) via Mongo call
TRANSFER_COMPLETED clear transfer_context from old encounter; on new encounter set transfer_arrived_at DELETE queue row resolve ack row → acknowledged if still pending none
TRANSFER_CANCELLED clear transfer_context; if wasAccepted: reopen source encounter (status back to IN_PROGRESS), close target encounter DELETE queue row cancel ack row If wasAccepted: emit compensation event to Mongo to reopen source + close target

6.2 Encounter flip on ACCEPTED

This is the only state where Mongo writes the encounter side-effect. The transferRequest service does this before emitting TRANSFER_ACCEPTED:

// services/administration/.../transferRequest.service.ts:acceptTransferRequest
await encounterRepository.update(req.encounterId, { status: ENTERED_IN_ERROR });
const newEnc = await encounterRepository.create({
  ...carryOver(sourceEncounter),
  subClinicId: req.toSubClinicId,
  status: PLANNED,
  refPreviousEncounter: req.encounterId,
});
await transferRequestRepository.update(req._id, {
  status: ACCEPTED,
  acceptedAt: now(),
  linkedNewEncounterId: newEnc._id,
});
await supabase.from('hospital_events').insert({
  event_type: 'TRANSFER_ACCEPTED',
  encounter_id: req.encounterId,
  payload: { ..., newEncounterId: newEnc._id },
});

Note: this is the same Mongo flip the legacy endpoint does — just gated behind the request lifecycle.


7. Supabase Read-Model Extension

7.1 New migration: 20260527a_transfer_request_read_model.sql

-- ─── 1. encounter_journey_cache: transfer_context column ────────────────────
ALTER TABLE encounter_journey_cache
  ADD COLUMN IF NOT EXISTS transfer_context JSONB DEFAULT NULL;

CREATE INDEX IF NOT EXISTS idx_ejc_transfer_pending
  ON encounter_journey_cache ((transfer_context->>'status'))
  WHERE transfer_context IS NOT NULL;

-- ─── 2. transfer_requests denormalized projection (optional but useful) ─────
CREATE TABLE IF NOT EXISTS transfer_requests (
  id                       UUID PRIMARY KEY,             -- == Mongo _id
  encounter_id             TEXT NOT NULL,
  patient_id               TEXT NOT NULL,
  from_clinic_id           TEXT,
  from_sub_clinic_id       TEXT,
  to_clinic_id             TEXT,
  to_sub_clinic_id         TEXT NOT NULL,
  to_bed_id                TEXT,
  reason                   TEXT NOT NULL,
  reason_detail            TEXT,
  urgency                  TEXT NOT NULL DEFAULT 'ROUTINE',
  status                   TEXT NOT NULL DEFAULT 'REQUESTED'
                             CHECK (status IN (
                               'REQUESTED','ACKNOWLEDGED','ACCEPTED',
                               'IN_PROGRESS','COMPLETED','REJECTED','CANCELLED'
                             )),
  requested_by             TEXT,
  requested_at             TIMESTAMPTZ NOT NULL,
  acknowledged_by          TEXT,
  acknowledged_at          TIMESTAMPTZ,
  accepted_by              TEXT,
  accepted_at              TIMESTAMPTZ,
  in_progress_at           TIMESTAMPTZ,
  completed_at             TIMESTAMPTZ,
  rejected_by              TEXT,
  rejected_at              TIMESTAMPTZ,
  reject_reason            TEXT,
  cancelled_by             TEXT,
  cancelled_at             TIMESTAMPTZ,
  cancel_reason            TEXT,
  linked_patient_transfer_id  TEXT,
  linked_new_encounter_id     TEXT,

  -- Denormalized display fields (hydrated by orchestrator from payload)
  patient_hn               TEXT,
  patient_name             TEXT,
  from_sub_clinic_name     TEXT,
  to_sub_clinic_name       TEXT,

  metadata                 JSONB DEFAULT '{}',
  created_at               TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at               TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_tr_to_subclinic_status
  ON transfer_requests (to_sub_clinic_id, status);
CREATE INDEX IF NOT EXISTS idx_tr_from_subclinic_status
  ON transfer_requests (from_sub_clinic_id, status);
CREATE INDEX IF NOT EXISTS idx_tr_encounter
  ON transfer_requests (encounter_id);
CREATE INDEX IF NOT EXISTS idx_tr_pending
  ON transfer_requests (status, urgency, requested_at)
  WHERE status IN ('REQUESTED','ACKNOWLEDGED');

-- ─── 3. Realtime ────────────────────────────────────────────────────────────
ALTER PUBLICATION supabase_realtime ADD TABLE transfer_requests;

-- ─── 4. RLS (mirror department_queues pattern) ──────────────────────────────
ALTER TABLE transfer_requests ENABLE ROW LEVEL SECURITY;

-- (Policies match existing dept-scoped pattern — TBD per region)

-- ─── 5. Trigger: department_queues sync on row insert/update ────────────────
-- Mirrors trg_sync_billing_queue pattern (see billing-queue-auto-sync.md).
-- Spawns/updates a department_queues row in the receiving dept's
-- 'transfer-incoming' tab whenever a transfer_requests row is
-- inserted/updated with status in (REQUESTED, ACKNOWLEDGED, ACCEPTED, IN_PROGRESS).
-- DELETEs queue row on terminal status.

CREATE OR REPLACE FUNCTION fn_sync_transfer_queue() RETURNS TRIGGER AS $$
DECLARE
  v_priority TEXT;
BEGIN
  v_priority := COALESCE(NEW.urgency, 'ROUTINE');

  IF NEW.status IN ('REQUESTED','ACKNOWLEDGED','ACCEPTED','IN_PROGRESS') THEN
    INSERT INTO department_queues (
      dept_type, ticket_id, encounter_id, patient_id, location_id,
      status, priority, patient_hn, patient_name, metadata
    ) VALUES (
      'transfer-incoming', NEW.id, NEW.encounter_id, NEW.patient_id, NEW.to_sub_clinic_id,
      NEW.status, v_priority, NEW.patient_hn, NEW.patient_name,
      jsonb_build_object(
        'transferRequestId', NEW.id,
        'fromSubClinicId', NEW.from_sub_clinic_id,
        'fromSubClinicName', NEW.from_sub_clinic_name,
        'reason', NEW.reason,
        'urgency', NEW.urgency,
        'requestedAt', NEW.requested_at
      )
    )
    ON CONFLICT (ticket_id) DO UPDATE SET
      status   = EXCLUDED.status,
      priority = EXCLUDED.priority,
      metadata = department_queues.metadata || EXCLUDED.metadata,
      updated_at = now();
  ELSIF NEW.status IN ('COMPLETED','REJECTED','CANCELLED') THEN
    DELETE FROM department_queues WHERE ticket_id = NEW.id;
  END IF;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER trg_sync_transfer_queue
  AFTER INSERT OR UPDATE ON transfer_requests
  FOR EACH ROW EXECUTE FUNCTION fn_sync_transfer_queue();

7.2 Why a Postgres trigger + an orchestrator handler

Two-layer write per encounter-orchestrator-triggers.md:

  • Orchestrator handler owns the cross-table fan-out (ack rows, journey cache, new-encounter flip) — anything that requires reading other tables or calling Mongo.
  • Postgres trigger owns the per-row department_queues mirror — same shape every time, no cross-table reads, idempotent. Mirrors trg_sync_billing_queue pattern from billing-queue-auto-sync.md.

7.3 transfer_context JSONB shape (on encounter_journey_cache)

{
  "transferRequestId": "abc-123",
  "status": "acknowledged",            // mirrors TransferRequestStatus
  "fromSubClinicId": "subclinic-im",
  "toSubClinicId":   "subclinic-cv",
  "fromSubClinicName": "อายุรกรรม",
  "toSubClinicName":   "หัวใจ",
  "reason": "SPECIALTY_REQUIRED",
  "urgency": "URGENT",
  "requestedAt": "2026-05-26T08:00:00Z",
  "acknowledgedAt": "2026-05-26T08:02:00Z",
  "linkedNewEncounterId": null         // set on ACCEPTED
}

UI consumers:

  • Patient profile sticky banner when status ∈ {requested, acknowledged} on the source encounter
  • Receiving-dept worklist row marker via dept_type='transfer-incoming'
  • IPD command-center “Pending transfers” widget (count of transfer_context.status='requested' on the ward)

8. Frontend Surfaces

8.1 Refactored DialogTransferPatient

web/packages/medical-kit/src/medical-record/components/transfer/transfer-patient/DialogTransferPatient.tsx

Changes:

  1. Header: replace hardcoded Title('Discharge: แผนกอายุรกรรม 37651/57') with dynamic t('transferRequest.dialogTitle') + actual dept/VN
  2. Submit calls POST /transferRequests instead of PUT /encounters/:id/transfer
  3. Add reason picker (enum, required) + urgency chip selector (defaults to ROUTINE)
  4. Status fields (สถานะผู้ป่วย: Transfer/End) → replace with urgency selector

8.2 New receiving-dept worklist tab

A new tab in every clinical worklist (medical-record, consult-request, IPD command center) auto-driven by department_queues rows where dept_type='transfer-incoming' and location_id=current.subClinicId.

Row actions:

Action Endpoint
Acknowledge PUT /transferRequests/:_id/acknowledge
Accept PUT /transferRequests/:_id/accept
Reject PUT /transferRequests/:_id/reject

Re-uses the acknowledgement_requests + AcknowledgementInbox FAB so the receiving nurse also sees it in the global ack inbox per acknowledgement-system.md.

8.3 Source-dept pending list

Source dept sees their own outgoing requests via GET /transferRequests?fromSubClinicId=…. Cancel button available until status reaches COMPLETED.


9. Cancellation / Compensation

9.1 Pre-ACCEPTED cancel

Just flips status, removes queue row + ack row. No Mongo encounter side-effects.

9.2 Post-ACCEPTED cancel (within ~30 min window)

Recovery path because the new encounter exists and the old is closed:

  1. Reopen source encounter: status: PLANNEDIN_PROGRESS (or whatever was preserved in metadata.previousStatus)
  2. Close target encounter: status: ENTERED_IN_ERROR
  3. Cancel PatientTransfer (porter job) if linkedPatientTransferId is set
  4. Emit TRANSFER_CANCELLED with wasAccepted: true
  5. Orchestrator clears transfer_context from both encounters’ journey caches

Beyond 30 min: require admin override (policy gate transfer_late_cancel). Cancelling once COMPLETED is disallowed.


10. Policy Gates

Per policy-gates.md, three new gates:

Gate name When evaluated Effect
transfer_request_create Before POST /transferRequests Blocks low-priv users from creating STAT transfers
transfer_accept Before PUT /accept Blocks accepting if receiving ward is at capacity (unless override)
transfer_late_cancel Before PUT /cancel when status >= ACCEPTED and (now - acceptedAt) > 30 min Requires admin override + cancel reason

11. Sequence Diagram — Happy Path

Source Nurse        DialogTransferPatient        Backend Mongo            Supabase
─────────────────────────────────────────────────────────────────────────────────────
   │
   │  click "Transfer Patient" on row
   │─────────────► open dialog
   │  pick toSubClinic + reason + urgency + remark
   │  click "Submit"
   │─────────────► POST /transferRequests
   │                         │
   │                         ├──► insert transfer_request (REQUESTED)
   │                         ├──► ctx.emit('clinical.transferRequest.created')
   │                         └──► supabase.from('hospital_events').insert({TRANSFER_REQUESTED})
   │                                                                              │
   │                                                       ┌──────────────────────┘
   │                                                       ▼
   │                                              orchestrator.handleTransferRequest
   │                                                       │
   │                                                       ├──► encounter_journey_cache.transfer_context = {...requested}
   │                                                       ├──► department_queues INSERT (dept_type=transfer-incoming)
   │                                                       └──► acknowledgement_requests INSERT
   │
   │                                                                       Realtime push
                                                                                  ▼
                                                                      Recv Nurse Worklist
                                                                      sees "Incoming Transfer"
                                                                              │
                                                                              │  click "Acknowledge"
                                                                              ▼
                                                              PUT /transferRequests/:id/acknowledge
                                                                              │
                                                                       (emits TRANSFER_ACKNOWLEDGED)
                                                                              │
                                                                              │  click "Accept"
                                                                              ▼
                                                              PUT /transferRequests/:id/accept
                                                                              │
                                                                              ├──► Mongo: close source encounter
                                                                              ├──► Mongo: create target encounter
                                                                              ├──► transfer_request.status = ACCEPTED
                                                                              └──► hospital_events: TRANSFER_ACCEPTED
                                                                                          │
                                                                              orchestrator updates both journeys
                                                                                          │
                                                                                          ▼
                                                              (porter or self-walk) → /start
                                                                                          │
                                                                                          ▼
                                                              (patient arrives)    → /complete

12. Invariants

  1. Source encounter stays open until ACCEPTED. No ENTERED_IN_ERROR flip on request creation.
  2. Only one open transfer per encounter. Enforced by service-layer check + index WHERE status IN ('REQUESTED','ACKNOWLEDGED','ACCEPTED','IN_PROGRESS') on (encounter_id).
  3. linkedNewEncounterId is set iff status >= ACCEPTED. Compensation logic relies on this.
  4. Receiving sub-clinic always gets an ack row — never a silent transfer.
  5. Orchestrator is idempotent per request id — replays don’t double-insert queue rows (uses ON CONFLICT (ticket_id)).
  6. Cancellation never bypasses ack lifecycle — cancelling a REQUESTED transfer resolves the pending ack as cancelled, not orphaned.
  7. PatientTransfer (porter dispatch) is optional — only spawned on /start and only for transfers needing physical transport.

13. Implementation Phases

Phase Scope Effort
P1: Backend entity TransferRequest Mongo entity + DTOs + service + controller + populate, 7 endpoints ~600 LOC
P2: Hospital_events emission Wire all 7 endpoints to emit, add to clinical.encounter.transferred webhook list ~120 LOC
P3: Medbase migration + trigger 20260527a_transfer_request_read_model.sql (this doc §7.1) ~150 LOC SQL
P4: Orchestrator handler handleTransferRequest + route in central dispatch ~250 LOC TS
P5: Frontend refactor Dialog refactor (reason + urgency + dynamic title), service layer, modalRegistry mapping cleanup ~200 LOC
P6: Receiving worklist New dept_type='transfer-incoming' queue rendering, row actions, AckInbox integration ~300 LOC
P7: Compensation Post-ACCEPTED cancel path + policy gate transfer_late_cancel ~150 LOC
P8: Demo + UAT Seed sample transfer requests, walkthrough script, playwright sandbox ~200 LOC

Total estimated: ~1,970 LOC across ~25 files in 7 directories.

Dependency graph:

P1 ──► P2 ──► P3 ──► P4 ──┐
                          ├──► P6 ──► P8
P5 ───────────────────────┘
                P3 ──► P7

P5 can ship first (dialog refactor + new endpoint shape) without P6/P7/P8 — that gives us the lifecycle without the read-model surface.


14. Open Questions

  1. Should transfer-incoming use department_queues or its own table? Reuse keeps the realtime subscription pattern consistent (location_id filter); separate table makes RLS easier. Recommend reuse + dept_type filter.
  2. Auto-acknowledge? If receiving dept has only one nurse and they viewed the queue, should we auto-flip to ACKNOWLEDGED? Probably no — explicit click is the audit trail.
  3. What about IPD ward-to-ward transfer vs OPD-to-OPD? Same entity. The toBedId field is optional, used only when IPD. Bed reservation is a separate concern handled by existing bedRequest entity.
  4. HL7v2 ADT^A02 (transfer)? Once this exists, the HL7v2 mapper (see hl7v2-parser) can route inbound transfer messages to this entity. Out of scope for P1-P8.
  5. FHIR resource? Maps to Task resource with code: 'transfer', for: Patient, owner: Practitioner/CareTeam. Add to FHIR transformers (see fhir-transformer-module.md) once entity exists.

15. References

Ask Anything