Transfer Request System
TransferRequest lifecycle replacing one-shot transfer: REQUESTED to ACCEPTED to COMPLETED, queue placement, compensation path, policy gates.
Unified design for a TransferRequest entity that replaces the current one-shot
PUT /encounters/:id/transferendpoint 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:
- Backend module
services/administration/.../modules/transferRequest/— service, controller mixin, repository, module, DTOs; REST endpoints covering the fullREQUESTED → ACKNOWLEDGED → ACCEPTED → IN_PROGRESS → COMPLETED / REJECTED / CANCELLEDlifecycle (GET/GETlist/GET incoming/:subClinicId/POST/PUT :id/acknowledge/PUT :id/accept/PUT :id/reject/PUT :id/start/PUT :id/complete/PUT :id/cancel/GET byEncounter/:encounterId).- Entity + enums in
packages/platform-api-schema/src/administration/transferRequest/.- Read-model migrations:
20260527a_transfer_request_read_model.sql(transfer_requeststable,encounter_journey_cache.transfer_contextJSONB,trg_sync_transfer_queue→dept_type='transfer-incoming', 3 policy gates) +20260527b_transfer_request_demo_seed.sql.- Orchestrator handler
handleTransferRequestinencounter-orchestrator/index.ts(defined ~6463, dispatched ~1045).- Frontend create path:
DialogTransferPatient.tsxnowPOSTs to/transferRequestsviacreateTransferRequestApi.🚧 Still open:
- No registered Transfer miniapp — there is no
DynamicCoreApp.TRANSFERenum entry, renderer case, or app-picker entry. (See the Transfer Navigator miniapp plan in memory.)- The worklist transfer-in / transfer-out tabs (
medical-kitcentral-worklist andmedical-recordTabPatientTransfer+PatientTransferIn/PatientTransferOutworkflows) are still MOCK data — not yet wired to thetransfer_requestsread 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
- Irreversible mid-failure. If the new-encounter creation throws after the source is closed, the patient is in limbo.
- No acknowledgment. Receiving sub-clinic’s charge nurse has no signal until the patient walks in.
- No structured reason. Only
remark: string. Quality teams can’t aggregate. - No orchestrator projection. The Moleculer event
clinical.encounter.transferredis consumed by webhook dispatcher only —encounter_journey_cache,department_queues, andacknowledgement_requestsnever update. Grep confirms: nohandleEncounterTransferredinencounter-orchestrator/index.ts. - No cancel-in-flight, no rejection. Once posted, the move is done.
- The
PatientTransferentity is something else. It’s the porter/stretcher dispatch withTransferStatus.DRAFT → PENDING → TRANSFERRING → COMPLETEDand assigned staff + checkpoints. Different concern.
1.2 Pattern this codebase uses for “X is requested → ack → accept → complete”
Reference implementations:
consultRequest— closest analog (clinical “ask another dept”)admission— entity +hospital_events+ orchestrator (seeadmission-to-ward-unified-contract.md)- [
bloodRequest,surgeryRequest,nutritionOrderRequest] — same shape
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/administration → RESOURCE_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_queuesmirror — same shape every time, no cross-table reads, idempotent. Mirrorstrg_sync_billing_queuepattern frombilling-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
Changes:
- Header: replace hardcoded
Title('Discharge: แผนกอายุรกรรม 37651/57')with dynamict('transferRequest.dialogTitle')+ actual dept/VN - Submit calls
POST /transferRequestsinstead ofPUT /encounters/:id/transfer - Add reason picker (enum, required) + urgency chip selector (defaults to ROUTINE)
- Status fields (
สถานะผู้ป่วย: Transfer/End) → replace withurgencyselector
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:
- Reopen source encounter:
status: PLANNED→IN_PROGRESS(or whatever was preserved inmetadata.previousStatus) - Close target encounter:
status: ENTERED_IN_ERROR - Cancel
PatientTransfer(porter job) iflinkedPatientTransferIdis set - Emit
TRANSFER_CANCELLEDwithwasAccepted: true - Orchestrator clears
transfer_contextfrom 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
- Source encounter stays open until
ACCEPTED. NoENTERED_IN_ERRORflip on request creation. - Only one open transfer per encounter. Enforced by service-layer check + index
WHERE status IN ('REQUESTED','ACKNOWLEDGED','ACCEPTED','IN_PROGRESS')on(encounter_id). linkedNewEncounterIdis set iffstatus >= ACCEPTED. Compensation logic relies on this.- Receiving sub-clinic always gets an ack row — never a silent transfer.
- Orchestrator is idempotent per request id — replays don’t double-insert queue rows (uses
ON CONFLICT (ticket_id)). - Cancellation never bypasses ack lifecycle — cancelling a
REQUESTEDtransfer resolves the pending ack ascancelled, not orphaned. PatientTransfer(porter dispatch) is optional — only spawned on/startand 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
- Should
transfer-incomingusedepartment_queuesor its own table? Reuse keeps the realtime subscription pattern consistent (location_idfilter); separate table makes RLS easier. Recommend reuse +dept_typefilter. - 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. - What about IPD ward-to-ward transfer vs OPD-to-OPD? Same entity. The
toBedIdfield is optional, used only when IPD. Bed reservation is a separate concern handled by existingbedRequestentity. - 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. - FHIR resource? Maps to
Taskresource withcode: 'transfer',for: Patient,owner: Practitioner/CareTeam. Add to FHIR transformers (seefhir-transformer-module.md) once entity exists.
15. References
acknowledgement-system.md— Universal ack-request entityencounter-orchestrator-triggers.md— When to use Postgres trigger vs. orchestrator handlerbilling-queue-auto-sync.md— Reference pattern for thetrg_sync_transfer_queuetriggeradmission-to-ward-unified-contract.md— Reference for full-stack entity + read-model docspolicy-gates.md— Where the 3 transfer gates plug inconsultRequest— Closest existing implementation analog- Legacy code being replaced:
encounter.service.ts:1116