Skip to content

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")

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

POST /api/v1/ratings/submit
GET  /api/v1/coordinator/{coordinator_id}/performance

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

  1. Coordinator RLS access path: Coordinator is in the shared tenant but needs to read patient cases from tenant-apollo-001. Access via CaseShare with COORDINATOR_FULL redaction policy + REFERENCE sharing 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.

  2. Facilitator access is consent-gated — patient can grant/revoke at any time. Access via CaseShare with FACILITATOR_CONSENT policy + ConsentRecord audit trail.

  3. Vendor contact info — coordinator-only, never shown to patients

  4. Driver details — shared with patient only after booking confirmed + patient_notified=true
  5. CSAT feedback — anonymized from coordinator view (coordinator sees scores, not rater identity)

Open Questions

  1. Coordinator tenant: Create a dedicated tenants record for Curaway internal staff, or use tenant-apollo-001 (the existing shared tenant)? Recommendation: Use existing shared tenant — coordinators are Curaway employees, not external orgs.

  2. 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.

  3. 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.