Multi-Tenancy Phase 3 — Facilitators + Coordinators¶
Status: Implemented in Session 50 ADR: 0018-multi-tenancy-platform-architecture Prerequisites: Phase 0 (RBAC, shares, redaction, state machine) ✓, Phase 1 (provider flow) ✓, Phase 2 (risk + MSO) ✓ Estimated effort: 3–4 weeks (backend + coordinator dashboard frontend)
Goal¶
Enable Curaway staff to manage patient cases from payment through recovery, and allow external facilitators to track delegated patient cases with consent-based access.
payment_locked
↓
coordinator_assigned ← [Auto-assign based on workload + tier]
↓
pre_op ← [Transport booking, preparation milestones]
↓
travel_booked ← [Airport pickup, hotel, hospital transfer]
↓
admitted ← [Patient at hospital]
↓
procedure_complete → post_op → follow_up → case_complete
Scope¶
What's In¶
| # | Deliverable | Layer |
|---|---|---|
| 3.1 | Coordinator assignment engine | Backend |
| 3.2 | Coordinator case queue API | Backend |
| 3.3 | Facilitator consent API (complete Phase 0 stubs) | Backend |
| 3.4 | Transport vendor directory + booking | Backend |
| 3.5 | Timeline manager | Backend |
| 3.6 | Escalation engine (stub — triggers only) | Backend |
| 3.7 | Ratings + CSAT collection | Backend |
| 3.8 | Coordinator dashboard pages | Frontend (coordinator-app) |
What's Out (Phase 4+)¶
- Full escalation engine with SMS/push alerts (Phase 4)
- Recovery milestone tracking (Phase 4 — ADR-0018 post-op flow)
- Payment/escrow integration (Phase 4)
- Facilitator commission tracking (Phase 4)
- Coordinator incentive system (Phase 4)
3.1 — Coordinator Assignment Engine¶
New Files¶
| File | Purpose |
|---|---|
app/services/coordinator_assignment_service.py |
Assignment logic |
app/models/coordinator.py |
New models (vendors, bookings, ratings) |
Service¶
class CoordinatorAssignmentService:
"""Auto-assign coordinators to cases at payment_locked.
Returns result — caller handles state transition.
"""
async def assign_coordinator(
self, db: AsyncSession, case_id: str, tenant_id: str
) -> CoordinatorAssignmentResult:
"""Find best coordinator and assign.
Ranking factors:
- Current workload (active cases): 0.40
- Transport tier match (T3/T4 → experienced coordinator): 0.25
- Language match: 0.20
- Availability (working hours, timezone): 0.15
Returns typed result — does NOT transition state.
"""
async def reassign(
self, db: AsyncSession, case_id: str, new_coordinator_id: str,
reason: str, reassigned_by: str
) -> CoordinatorAssignmentResult:
"""Manual reassignment with audit trail."""
Case Model Extension¶
# Add to app/models/case.py:
assigned_coordinator_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
3.2 — Coordinator Case Queue API¶
New Files¶
| File | Purpose |
|---|---|
app/services/coordinator_queue_service.py |
Queue + filtering |
app/routers/coordinator_portal.py |
Coordinator endpoints |
app/schemas/coordinator.py |
Pydantic schemas |
Endpoints¶
GET /api/v1/coordinator/queue
→ Paginated case list for assigned coordinator
→ Filters: phase, transport_tier, country, urgency
→ Sort: urgency desc (pre_op first), then assigned_at asc
→ Requires: @require_permission("case:queue:view")
GET /api/v1/coordinator/cases/{case_id}
→ Full case detail (no redaction — coordinator sees everything)
→ Requires: @require_permission("case:read:assigned")
POST /api/v1/coordinator/assign
→ Auto-assign or manual assign coordinator to case
→ Body: { case_id, coordinator_id? (optional — auto if omitted) }
→ Transitions: payment_locked → coordinator_assigned
→ Requires: @require_permission("case:assign")
3.3 — Facilitator Consent API¶
Complete the Phase 0 stubs in CaseShareService.
DESIGN DECISION: Two-step flow (matches Phase 0 CaseShare design):
1. grant creates the CaseShare AND a real ConsentRecord in one transaction
2. revoke deactivates the share and records revocation on the ConsentRecord
The Phase 0 grant_facilitator_consent(share_id) method works on an existing share.
Phase 3 changes this to a single grant endpoint that creates share + consent together
(patient doesn't need to know about shares — they just grant/revoke access).
ConsentRecord integration: Each facilitator grant creates a ConsentRecord with
purpose_code='facilitator_data_sharing' in the existing consent_records table.
The consent_record_id FK on CaseShare links to this record for audit compliance.
Endpoints¶
POST /api/v1/consent/facilitator/grant
→ Patient grants facilitator access to their case
→ Body: { case_id, facilitator_id }
→ Creates: ConsentRecord (purpose_code='facilitator_data_sharing')
→ Creates: CaseShare (sharing_mode=REFERENCE, redaction_policy=FACILITATOR_CONSENT,
consent_record_id=new_consent.id, consent_granted=true)
→ Requires: case owner (patient)
POST /api/v1/consent/facilitator/revoke
→ Patient revokes facilitator access
→ Body: { case_id, facilitator_id }
→ Updates: CaseShare consent_granted=false, is_active=false
→ Updates: ConsentRecord with revocation timestamp
→ Requires: case owner
GET /api/v1/consent/facilitator/list
→ Show all facilitators with access to patient's cases
→ Requires: case owner
GET /api/v1/facilitator/delegated-cases
→ List cases delegated to this facilitator
→ Only shows cases where consent_granted=true
→ Requires: @require_permission("case:read:delegated")
3.4 — Transport Vendor Directory + Booking¶
New Models (in app/models/coordinator.py)¶
class CoordinatorVendor(Base, UUIDPrimaryKeyMixin, TenantScopedMixin, TimestampMixin):
"""Transport/logistics vendor."""
__tablename__ = "coordinator_vendors"
vendor_name: Mapped[str] = mapped_column(String(255), nullable=False)
service_type: Mapped[str] = mapped_column(String(50), nullable=False)
# ground_transport | air_ambulance | medical_escort | accommodation | interpreter
country_code: Mapped[str] = mapped_column(String(3), nullable=False)
city: Mapped[str | None] = mapped_column(String(100))
contact_name: Mapped[str | None] = mapped_column(String(255))
contact_email: Mapped[str | None] = mapped_column(String(255))
contact_phone: Mapped[str | None] = mapped_column(String(50))
contract_status: Mapped[str] = mapped_column(String(20), default="active")
service_capabilities: Mapped[dict] = mapped_column(FlexibleJSON, default=dict)
coverage_area: Mapped[dict] = mapped_column(FlexibleJSON, default=dict)
sla_response_minutes: Mapped[int | None] = mapped_column(Integer)
cost_model: Mapped[dict] = mapped_column(FlexibleJSON, default=dict)
rating_internal: Mapped[float | None] = mapped_column(Float)
class CoordinatorServiceBooking(Base, UUIDPrimaryKeyMixin, TenantScopedMixin, TimestampMixin):
"""Transport/service booking for a case."""
__tablename__ = "coordinator_service_bookings"
case_id: Mapped[str] = mapped_column(String(36), ForeignKey("cases.id"), nullable=False, index=True)
vendor_id: Mapped[str] = mapped_column(String(36), ForeignKey("coordinator_vendors.id"), nullable=False)
coordinator_id: Mapped[str] = mapped_column(String(255), nullable=False)
service_type: Mapped[str] = mapped_column(String(50), nullable=False)
booking_type: Mapped[str] = mapped_column(String(20), default="scheduled")
# scheduled | emergency
journey_leg: Mapped[str] = mapped_column(String(50), nullable=False)
# origin_to_airport | airport_to_hotel | hotel_to_hospital | hospital_to_recovery | etc.
status: Mapped[str] = mapped_column(String(20), default="requested")
# requested → confirmed → driver_assigned → in_progress → completed → cancelled
pickup_location: Mapped[dict | None] = mapped_column(FlexibleJSON)
dropoff_location: Mapped[dict | None] = mapped_column(FlexibleJSON)
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
actual_pickup_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
actual_dropoff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
vehicle_type: Mapped[str | None] = mapped_column(String(50))
medical_escort_required: Mapped[bool] = mapped_column(Boolean, default=False)
driver_details: Mapped[dict | None] = mapped_column(FlexibleJSON)
cost_quoted_cents: Mapped[int | None] = mapped_column(Integer)
cost_actual_cents: Mapped[int | None] = mapped_column(Integer)
cost_currency: Mapped[str] = mapped_column(String(3), default="USD")
patient_notified: Mapped[bool] = mapped_column(Boolean, default=False)
Endpoints¶
GET /api/v1/coordinator/vendors
→ List vendors filtered by city, service_type, country
POST /api/v1/coordinator/bookings
→ Create transport booking
PATCH /api/v1/coordinator/bookings/{booking_id}
→ Update status (confirmed, driver_assigned, in_progress, completed)
GET /api/v1/coordinator/bookings?case_id={case_id}
→ All bookings for a case
3.5 — Timeline Manager¶
Service¶
@dataclass
class TimelineEntry:
name: str
entry_type: str # milestone | event | note
timestamp: str | None
expected_date: str | None
status: str # upcoming | completed | overdue
actor: str | None
note_text: str | None = None
@dataclass
class TimelineResult:
milestones: list[TimelineEntry]
events: list[TimelineEntry]
notes: list[TimelineEntry]
class TimelineService:
"""Manage case milestones and coordinator notes.
Milestones stored in case.workflow_state.timeline JSONB.
"""
async def get_timeline(self, db, case_id, tenant_id) -> TimelineResult:
"""Return sorted milestones + events."""
async def set_milestone(self, db, case_id, milestone_name, expected_date, updated_by) -> TimelineEntry:
"""Set or update a milestone date."""
async def add_note(self, db, case_id, note_text, note_type, added_by) -> TimelineEntry:
"""Add coordinator note (internal or patient-visible)."""
Endpoints¶
GET /api/v1/cases/{case_id}/timeline
PATCH /api/v1/cases/{case_id}/timeline/milestone/{name}
POST /api/v1/cases/{case_id}/timeline/note
3.6 — Escalation Engine (Stub)¶
Phase 3 creates the framework. Phase 4 adds SMS/push triggers.
class EscalationService:
"""Flag cases requiring coordinator attention."""
async def create_escalation(self, db, case_id, reason, severity, trigger_type):
"""Create an escalation event. Logs to audit."""
async def resolve_escalation(self, db, escalation_id, action_taken, resolved_by):
"""Mark escalation as resolved."""
async def get_active_escalations(self, db, coordinator_id):
"""Active escalations for a coordinator's assigned cases."""
Trigger types (Phase 3 — manual only): - coordinator_manual: coordinator flags a case - system_overdue: milestone overdue > 48h (checked by cron stub)
Phase 4 adds: patient_reported, keyword_detection, missing_checkin.
3.7 — Ratings + CSAT¶
New Models¶
class ActorRating(Base, UUIDPrimaryKeyMixin, TenantScopedMixin, TimestampMixin):
"""Patient rating for facilitator or coordinator."""
__tablename__ = "actor_ratings"
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
# facilitator | coordinator
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
case_id: Mapped[str] = mapped_column(String(36), ForeignKey("cases.id"), nullable=False)
rater_id: Mapped[str] = mapped_column(String(255), nullable=False)
rating: Mapped[Decimal] = mapped_column(Numeric(2, 1), nullable=False) # 1.0–5.0
feedback: Mapped[str | None] = mapped_column(Text)
class CoordinatorCSAT(Base, UUIDPrimaryKeyMixin, TenantScopedMixin, TimestampMixin):
"""Case-level CSAT for coordinator performance."""
__tablename__ = "coordinator_csat"
case_id: Mapped[str] = mapped_column(String(36), ForeignKey("cases.id"), nullable=False)
coordinator_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
communication_score: Mapped[int] = mapped_column(Integer) # 1-5
timeliness_score: Mapped[int] = mapped_column(Integer)
helpfulness_score: Mapped[int] = mapped_column(Integer)
overall_score: Mapped[int] = mapped_column(Integer)
feedback: Mapped[str | None] = mapped_column(Text)
Endpoints¶
Implementation Order¶
Week 1:
Day 1-2: Coordinator models + migration + assignment service
Day 3-4: Case queue API + coordinator portal endpoints
Day 5: Facilitator consent API (complete stubs)
Week 2:
Day 1-2: Transport vendor + booking models + migration + API
Day 3-4: Timeline manager
Day 5: Escalation engine stub
Week 3:
Day 1-2: Ratings + CSAT models + API
Day 3-4: Coordinator dashboard frontend (coordinator-app pages)
Day 5: Feature flags + integration tests
Week 4:
Day 1-2: E2E testing + code review
Day 3: Polish pass
Feature Flags¶
| Flag | Default | Purpose |
|---|---|---|
coordinator_assignment_enabled |
false |
Auto-assign at payment_locked |
facilitator_consent_enabled |
false |
Facilitator consent grant/revoke |
coordinator_queue_enabled |
false |
Coordinator case queue dashboard |
coordinator_services_transport_v1 |
false |
Transport vendor + booking |
coordinator_timeline_enabled |
false |
Timeline milestones + notes |
coordinator_escalation_enabled |
false |
Escalation triggers |
coordinator_csat_enabled |
false |
CSAT collection |
Test Plan¶
| Area | Tests (est.) |
|---|---|
| Coordinator assignment (workload, tier, language) | 10–12 |
| Case queue (filtering, pagination, sorting) | 8–10 |
| Facilitator consent (grant, revoke, list, delegation) | 10–12 |
| Transport vendors + bookings | 10–12 |
| Timeline (milestones, notes) | 6–8 |
| Escalation (create, resolve, list) | 6–8 |
| Ratings + CSAT | 6–8 |
| State transitions (coordinator flow) | 8–10 |
| Total | ~65–80 |
Migration¶
xxxx_01_add_coordinator_models.py
→ assigned_coordinator_id on cases
→ coordinator_vendors table
→ coordinator_service_bookings table
→ actor_ratings table
→ coordinator_csat table
→ RLS on all new tables
→ Grants to curaway_app role
Uses DATABASE_URL_ADMIN (superuser).
Security¶
-
Coordinator RLS access path: Coordinator is in the shared tenant but needs to read patient cases from
tenant-apollo-001. Access via CaseShare withCOORDINATOR_FULLredaction policy +REFERENCEsharing mode. When a coordinator is assigned, a CaseShare is auto-created (source=patient tenant, target=shared tenant, redaction=coordinator_full). The dual-scope RLS on case_shares lets the coordinator read. The coordinator_full redaction policy passes all fields through (no masking). This is the same pattern as facilitators but with full access instead of consent-gated. -
Facilitator access is consent-gated — patient can grant/revoke at any time. Access via CaseShare with
FACILITATOR_CONSENTpolicy + ConsentRecord audit trail. -
Vendor contact info — coordinator-only, never shown to patients
- Driver details — shared with patient only after booking confirmed +
patient_notified=true - CSAT feedback — anonymized from coordinator view (coordinator sees scores, not rater identity)
Open Questions¶
-
Coordinator tenant: Create a dedicated
tenantsrecord for Curaway internal staff, or usetenant-apollo-001(the existing shared tenant)? Recommendation: Use existing shared tenant — coordinators are Curaway employees, not external orgs. -
Assignment at scale: When 100+ cases/day, should assignment be round-robin or weighted? Recommendation: Weighted by workload (Phase 3), add ML-based routing in Phase 5.
-
Booking cost visibility: Does the patient see transport costs, or only the coordinator? Recommendation: Patient sees estimated total in their case summary. Itemized vendor costs are coordinator-only.
Deviations from Spec¶
- Coordinator dashboard frontend (item 3.8) bootstrapped but not shipped in Session 50; backend-only in this phase.
- Escalation engine (item 3.6) is trigger-only in Phase 3; SMS/push alerts deferred to Phase 4 as specced.
- Coordinator tenant uses existing shared tenant (
tenant-apollo-001) rather than a dedicated coordinator tenant — per open question resolution. - Booking cost visibility to patient (estimated total in case summary) deferred to Phase 4.
- ML-based coordinator routing deferred to Phase 5; weighted workload routing shipped in Phase 3.