Facilitator Portal Frontend — Feature Spec¶
Status: Draft — pending SD review
Author: Claude (Opus, planning role) — 2026-05-03
Target path: docs/specs/facilitator-portal-frontend-feature.md
Related epic: #244 (portal completion epic)
Related ADR: ADR-0018 — Multi-Tenancy + 7-Actor Platform Architecture
Reference router: app/routers/facilitator_portal.py (lines 1–181)
1. Goal + Non-Goals¶
Goal¶
Ship apps/facilitator-app/ — a Vite + React 18 + Clerk SPA that gives external Curaway facilitators (independent referral agents per ADR-0018 row 20) a self-service portal to:
- See cases that patients have delegated to them.
- Manage the consent grant/revoke flow for their own facilitator-id (where applicable — see §10 Decision Points).
- View ratings they've received from patients.
- (Stretch, behind feature flag) Track commissions and manage referral links.
The portal must be visually and architecturally indistinguishable from apps/{admin,coordinator,mso,provider}-app/ — same ClerkProvider, same PortalOrgGate, same ApiClient from @curaway/shared-core, same Teal/Coral/Deep-Ocean palette, same Montserrat + Crimson Pro typography, same 8px grid, same WCAG AA target.
Non-Goals (deferred)¶
- Commission ledger backend — no model, no router, no service exists today (
grep -li commission app/returns onlyapp/seed_graph.py, where it appears as a Neo4j relationship label, not application logic). See §10 Decision Points D4 and §12 Out of Scope. - Referral link backend — no
referral_link/referral_codetable, no public landing page wired to attribution. See §10 Decision Points D5. - Case-source attribution layer — no
facilitatorstable, noPatient.referred_by_facilitator_idFK, noCase.referred_by_facilitator_id. Today onlyPatient.referral_source(free-text, max 100 chars) exists. See §5.4b + D13. This is a deeper hole than D4/D5 alone: without attribution, commissions can't be paid and referral links have nothing to attribute to. Must ship before Phase 4 lifts the placeholders. - Patient delegation REQUEST inbox — the current API exposes only
/consent/facilitator/list(patient-side: who has access to me) and/facilitator/delegated-cases(facilitator-side: cases granted to me). There is no concept of a pending invitation/request in the data model —CaseShare.is_activeis binary (granted or not). See §10 D2 and §12. - Facilitator-side ratings GET endpoint — only
/api/v1/coordinator/{coordinator_id}/performanceexists (app/routers/coordinator_portal.py:695). It is hard-coded tocoordinator_idsemantics. See §10 D3. - Patient roster / case detail viewer beyond the share metadata — the
DelegatedCaseItemschema (app/schemas/facilitator.py:60-67) only returnsshare_id,case_id,source_tenant_id,consent_granted,created_at. Rich case detail (procedure, patient demographics, status) requires per-case access throughcase_accessmiddleware which currently exempts facilitators. Out of scope until Phase 4. - In-portal messaging / chat with patient or coordinator — Phase 4.
- Mobile-native app — responsive web only (375px tested).
- i18n / non-English — English-only, with
dir="auto"on text containers per.claude/rulesfrontend section. - Onboarding / KYC of new facilitators — invite-only, manual provisioning by platform admin (see §6 Tenant + Auth).
2. User Stories¶
| # | As a... | I want... | So that... |
|---|---|---|---|
| US-1 | Facilitator who just signed in | to see a list of all cases delegated to me by patients I've referred | I can confirm the platform sees the relationship and I have something to act on |
| US-2 | Facilitator with multiple delegated cases | to see when each delegation was granted and whether consent is still active | I know which patients have not revoked me and I can prioritize live cases |
| US-3 | Facilitator who lost access mid-session | to see a clear "consent revoked" empty state and a path to ask the patient to re-grant | I don't get a 403 page and assume the platform is broken |
| US-4 | Facilitator viewing my ratings | to see the average rating across all cases plus the individual feedback strings | I can improve and prove my service quality to prospective referrals |
| US-5 | Facilitator with a referral link (Phase 4 / flagged) | to copy a unique URL I can text to a prospective patient | I get attribution credit when they sign up |
User stories US-1, US-2, US-3, US-4 are deliverable on the existing backend. US-5 is gated behind future-work backend (D5).
3. Architecture¶
3.1 Repo location¶
- App folder:
apps/facilitator-app/incuraway-health-navigator - Package name:
@curaway/facilitator-app(mirrorsapps/admin-app/package.json:2) - Workspaces: monorepo root
package.jsonalready discoversapps/*(no change needed there) - Shared deps:
@curaway/shared-core(ApiClient, resolveTenantId, ApiError) +@curaway/shared-web(PortalOrgGate, SettingsPage, cn util) - Bundler: Vite 5 +
@vitejs/plugin-react-swc— copyapps/admin-app/vite.config.ts, change dev port. Verified existing allocations: patient=8080, admin=8081, coordinator=8081 (pre-existing collision, not in scope to fix here), provider=8082, design-system=8083, mso=8084. Pick facilitator=8085 (next free).
3.2 Vercel project¶
| Portal | Vercel project name | Subdomain |
|---|---|---|
| admin | curaway-admin |
admin.curaway.ai |
| coordinator | curaway-coordinator |
coordinator.curaway.ai |
| mso | curaway-mso |
mso.curaway.ai |
| provider | curaway-provider |
provider.curaway.ai |
| facilitator (new) | curaway-facilitator |
facilitator.curaway.ai |
Confirm project name with SD before vercel link. SD-decision: D7.
3.3 Required env vars (mirror apps/coordinator-app/.env.example)¶
VITE_CLERK_PUBLISHABLE_KEY=pk_live_… (or pk_test_… for dev)
VITE_API_BASE_URL=https://services.curaway.ai
VITE_TENANT_ID=tenant-curaway-facilitators # local-dev fallback only
VITE_TENANT_ID is the local-dev fallback — production uses resolveTenantId(orgId, publicMetadata) from packages/shared-core/src/tenant-map.ts:161 which prefers Clerk org publicMetadata.tenant_id then the runtime org→tenant map.
3.4 Source tree (target)¶
apps/facilitator-app/
├── index.html
├── package.json # mirrors apps/admin-app/package.json
├── postcss.config.js # copy
├── tailwind.config.ts # copy (no edits)
├── tsconfig.json # copy
├── vercel.json # copy verbatim — security headers + SPA rewrites
├── vite.config.ts # copy, port 8084
├── .env.example # tenant-curaway-facilitators
└── src/
├── App.tsx # ClerkProvider + PortalOrgGate(portal="facilitator")
├── App.test.tsx
├── main.tsx # identical to all 4 portals
├── index.css # copy admin (or coordinator) — tokenized HSL vars
├── lib/
│ └── api-client.ts # useApiClient + useTenantId — IDENTICAL pattern
├── hooks/
│ ├── useApiQuery.ts # copy verbatim from coordinator-app
│ └── useFacilitatorApi.ts
├── services/
│ └── facilitatorApi.ts # service class wrapping ApiClient
├── components/
│ ├── ApiErrorBoundary.tsx # copy verbatim from coordinator-app
│ └── FacilitatorLayout.tsx # icon rail + main + UserButton
├── pages/
│ ├── Auth.tsx # split-pane brand panel (right) + sign-in (left)
│ ├── DelegatedCases.tsx # primary landing (US-1, US-2, US-3)
│ ├── CaseDetail.tsx # share metadata + revoke-status awareness
│ ├── DelegationRequests.tsx # rendered as "All delegations" until D2 resolved
│ ├── Ratings.tsx # gated behind D3 / FF
│ ├── Commissions.tsx # gated behind FF (default off, D4)
│ ├── ReferralLinks.tsx # gated behind FF (default off, D5)
│ └── Settings.tsx # uses shared SettingsPage(portal="facilitator")
└── test/
└── setup.ts # jest-dom setup, copy from coordinator-app
3.5 Routing (mirrors apps/coordinator-app/src/App.tsx:69-78)¶
/ → redirect to /cases
/auth (signed-out only — split tab Sign In / Sign Up)
/cases DelegatedCases (default landing)
/cases/:caseId CaseDetail
/delegations DelegationRequests (single list — pending vs active = D2)
/ratings Ratings (FF gated — see D3)
/commissions Commissions (FF gated — D4)
/referrals ReferralLinks (FF gated — D5)
/settings SettingsPage(portal="facilitator")
Routes outside the FF-disabled set fall back to <Navigate to="/cases" replace />.
3.6 Shared-package contract is already facilitator-aware¶
packages/shared-web/src/components/auth/PortalOrgGate.tsx:38declares'facilitator'as aPortalTypeand provides label"Facilitator"at line 54. Picker filterspublicMetadata.portal_type === 'facilitator'org memberships at line 110-115. Zero changes required in the shared package.packages/shared-core/src/tenant-map.ts:40-52does not currently ship a static seed for the facilitator org. This must be fixed in two places (defense-in-depth, mirroring the other portals):- Provisioning step (mandatory):
publicMetadata.tenant_id = "tenant-curaway-facilitators"set on the Clerk org at creation time (§6.3 step 2).resolveTenantIdreadspublicMetadata.tenant_idfirst. - Static seed (mandatory): add
(<org_clerk_facilitator_id>, "tenant-curaway-facilitators")toORG_TENANT_MAPintenant-map.ts:40-52AND to_ORG_TENANT_MAP_FALLBACKinapp/middleware/rbac_middleware.pyso the resolution doesn't depend on the dynamic/public/tenant-mapfetch on first render or while offline. See §9 Edge Cases EC-3.
4. Backend Integration Table¶
4.1 Endpoint × page × action matrix¶
| Endpoint | File:line | Permission | Tenant context | Page that calls it | Action |
|---|---|---|---|---|---|
POST /api/v1/consent/facilitator/grant |
app/routers/facilitator_portal.py:46 |
consent:facilitator:grant |
X-Tenant-ID from active Clerk org |
NOT called by facilitator portal. Patient-app only — request.state.user_id is the patient id. See §10 D1. |
n/a |
POST /api/v1/consent/facilitator/revoke |
app/routers/facilitator_portal.py:83 |
consent:facilitator:revoke |
header | NOT called by facilitator portal — patient-app calls. | n/a |
GET /api/v1/consent/facilitator/list |
app/routers/facilitator_portal.py:116 |
consent:facilitator:list |
header | NOT called by facilitator portal (lists from the patient's perspective). | n/a |
GET /api/v1/facilitator/delegated-cases |
app/routers/facilitator_portal.py:148 |
case:read:delegated |
X-Tenant-ID (facilitator's tenant) + JWT-resolved user_id (request.state.user_id) maps to facilitator_id |
DelegatedCases.tsx, DelegationRequests.tsx, CaseDetail.tsx |
Initial load + retry |
POST /api/v1/ratings/submit |
app/routers/coordinator_portal.py:657 |
rating:submit |
header | NOT called by facilitator portal. Patient-app submits ratings. | n/a |
GET /api/v1/coordinator/{coordinator_id}/performance |
app/routers/coordinator_portal.py:695 |
coordinator:performance:read |
header | Ratings.tsx — only if SD agrees to reuse the endpoint with actor_id=facilitator_id (D3). |
Aggregated CSAT view |
4.2 Realized backend surface for the facilitator portal¶
Effectively one endpoint: GET /api/v1/facilitator/delegated-cases.
The other three endpoints in facilitator_portal.py are patient-facing (the JWT subject is the patient, who grants/revokes/lists facilitators on their own cases). They live in this router by domain ("facilitator consent flow") but are not wired into the facilitator's own UI.
This is the most important finding of the spec — see §10 D1.
4.3 Response envelope¶
Every endpoint returns the standard { success: true, data: ... } envelope. The shared ApiClient.unwrap() at packages/shared-core/src/api-client.ts:28-43 strips the envelope, so service-layer code receives data directly. List endpoints return data: DelegatedCaseItem[] (the router uses [item.model_dump() for item in items] at line 180).
4.4 Required headers (every request)¶
Authorization: Bearer <Clerk JWT>— set byApiClient.headers()(packages/shared-core/src/api-client.ts:56-64)X-Tenant-ID: <resolved tenant>— set by ApiClient when constructed with non-empty tenantIdX-Correlation-ID: <uuid>— auto-generated per request (line 61)Content-Type: application/json— for write endpoints
4.5 Error envelope shapes (handled by ApiClient + ApiErrorBoundary)¶
401— Clerk JWT missing or expired (refresh failed).ApiErrorBoundary.tsx:38-44redirects to/auth.403 AUTH_PERMISSION_DENIED— RBAC fail (app/middleware/require_permission.py:78-85)403 FEATURE_DISABLED—is_feature_enabled("facilitator_consent_enabled", tenant_id)returned false (facilitator_portal.py:34-40).ApiErrorBoundary.tsx:52-61renders this as the friendly "Not yet available" panel.404— case share not found (revoke path)500/ network — generic retry surface
5. Page-by-Page UX¶
5.1 Page 1 — DelegationRequests.tsx (route: /delegations)¶
Purpose: Single chronological list of "every patient who has delegated me." Until D2 is resolved, there is no notion of a pending request — every record returned by /facilitator/delegated-cases already has consent_granted=true (the service filters at facilitator_consent_service.py:227-228). So the "inbox" framing from the #244 epic copy is aspirational; we render what the API gives us.
Data model (per row):
interface DelegatedCaseItem {
share_id: string; // CaseShare.id
case_id: string; // Case.id
source_tenant_id: string; // tenant the patient lives in
consent_granted: boolean; // always true today (filter in service)
created_at: string | null; // ISO 8601 UTC
}
Endpoint: GET /api/v1/facilitator/delegated-cases
Primary action: click row → navigate to /cases/:case_id (CaseDetail).
Secondary actions:
- Sort by created_at (desc default)
- Filter by source_tenant_id (chip — only shown if >1 tenant in result set)
- Refresh button (calls retry from useApiQuery)
Edge states:
- Loading: skeleton rows via <SkeletonRows count={5}/> (component already exists at apps/coordinator-app/src/components/ApiErrorBoundary.tsx:16-24 — copy)
- Empty: "No cases delegated to you yet. Patients you refer will appear here once they grant consent." Icon: LayoutList (lucide-react, already imported by other portals)
- Error 500/network: ApiErrorBoundary shows "Failed to load data" with Retry — already wired
- 403 FEATURE_DISABLED: "Not yet available" copy — already wired
- 403 AUTH_PERMISSION_DENIED: auto-redirect to /auth — handled by ApiErrorBoundary.tsx:38-44
- Unauthorized (401): auto-redirect to /auth
Mobile (375px):
- Single-column list (no table). Each row stacks: case_id (mono small), source_tenant chip, "granted X days ago" relative timestamp.
- Touch target ≥44px (already enforced by .claude/rules/frontend).
- Filter chips collapse into a "Filters (n)" sheet.
shadcn/ui components reused (already installed in @curaway/shared-web):
- Card, Button, Badge — consistent with coordinator portal CaseQueue tab styling
- Custom FilterChip — copy from apps/coordinator-app/src/pages/CaseQueue.tsx:101-181
- SkeletonRows from coordinator's ApiErrorBoundary.tsx
5.2 Page 2 — DelegatedCases.tsx (route: /cases, default landing)¶
Functionally identical to DelegationRequests for v1. The split into two pages exists to match the #244 epic phrasing, but until the backend grows a "pending request" concept (D2) they show the same data with different framing copy:
/delegationsheadline: "Patient delegations" / subtitle: "Cases shared with you"/casesheadline: "My cases" / subtitle: "Cases you're actively managing"
Recommendation (D2): consolidate into one route until backend distinction exists. SD to confirm.
5.3 Page 3 — CaseDetail.tsx (route: /cases/:caseId)¶
Purpose: show the limited share-level metadata for a single delegated case. Today this is only the fields in DelegatedCaseItem — no procedure name, no patient name, no status timeline, because the facilitator role does not currently pass case_access middleware ownership checks.
Data model: fetch /facilitator/delegated-cases on mount (do NOT assume the list page populated a cache — deep-link refresh of /cases/:caseId is a real flow), then filter client-side by case_id. (No /facilitator/delegated-cases/{case_id} endpoint exists.)
Primary action: none — read-only view.
Secondary actions: - Copy case_id button - Link out to coordinator (mailto / future messaging — D8)
Edge states:
- Case_id not in result set: "This case is no longer shared with you. The patient may have revoked your access." — distinct from generic 404 because the API never returned 404; we just didn't see the row. Show with ShieldOff icon.
- Stale data (consent revoked between list load and detail click): same copy.
- All standard loading/error states.
Mobile (375px):
- Single-column metadata list. share_id truncated with copy button. Created-at as relative + absolute on long-press.
5.4 Page 4 — Ratings.tsx (route: /ratings, FF-gated)¶
Purpose: show ratings the facilitator has received from patients.
Backend dependency (D3): NO endpoint currently exposes facilitator-side aggregated ratings. /api/v1/coordinator/{coordinator_id}/performance is permission-gated by coordinator:performance:read (granted to coordinator role only — see alembic/versions/824b672ec7a4_grant_portal_permissions_to_roles.py:91). The SQL underneath (rating_service.py:80-108) does in fact filter only by actor_id and tenant_id, NOT by actor_type, so the data would come back correctly if we passed a facilitator_id — but the route is named after the coordinator semantically, the permission is coordinator-only, and the response field is coordinator_id.
Two options for SD (D3):
1. Reuse the endpoint — grant the coordinator:performance:read permission to the facilitator role too, rename the endpoint to /api/v1/actors/{actor_id}/performance, and add actor_type filtering server-side. Backend ticket required.
2. Hide page until v2 — render "Ratings coming soon" placeholder; ship the route behind FF facilitator_ratings_enabled (default off).
This spec defaults to option 2 — see §10 D3.
If/when option 1 lands:
- Data: PerformanceResponse (app/schemas/facilitator.py:129-136): total_ratings, average_rating, total_cases_completed, ratings: RatingResponse[]
- Layout: copy apps/coordinator-app/src/pages/Performance.tsx 1:1 — KPI tiles (Average, Total ratings, Cases completed) + scrollable rating row list with star renderer + feedback text.
- Edge states: 0 ratings → "No ratings yet. They'll appear here after patients submit feedback."
5.4b Case-source attribution — gap analysis (audit 2026-05-03)¶
Before any of §5.5 (Commissions) or §5.6 (Referral Links) can ship, we must answer: can we identify cases generated by facilitators vs direct patients? Audit verdict: no, not today. What exists in the data model:
| Surface | What it captures | What it doesn't |
|---|---|---|
Patient.referral_source: str(100) (app/models/patient.py:80) |
Free-text source string set at registration (e.g. "google_search") |
No FK, no schema, no facilitator entity |
CaseShare row with actor_type='facilitator' |
Post-hoc delegation — patient grants the facilitator access to an already-existing case | NOT source attribution. The case was created independently; the share record only proves the facilitator was later granted read access. |
roles[code='facilitator'] + CaseShare.actor_id |
Role grant + Clerk user_id | No facilitators table, no FK from Patient or Case to a structured facilitator record |
Implication: the DelegatedCases page (US-1) accurately answers "which cases am I authorized to see?" but cannot answer "which cases did I generate?" — that distinction has no database backing today. Commissions (D4) and Referral Links (D5) both implicitly assume an attribution layer that doesn't exist.
Required attribution layer to unblock Phase 4 (now tracked as D13 in §10):
1. New facilitators table — id, tenant_id (= tenant-curaway-facilitators), name, clerk_user_id, commission_pct, is_active, audit cols. Decoupled from the roles system so a facilitator entity can pre-exist their Clerk signup (invite-only flow).
2. New column Patient.referred_by_facilitator_id: str | None (FK → facilitators.id). Backfill: NULL for all 41 existing patients (direct signups). Set at registration via referral cookie (D5 dependency) or admin override.
3. (Optional, performance) denormalize to Case.referred_by_facilitator_id on creation — saves a JOIN through Patient for commission queries.
4. New endpoint GET /api/v1/facilitator/sourced-cases (separate from delegated-cases) — lists cases the facilitator originated, regardless of whether they currently hold a CaseShare for them.
Until D13 ships, Commissions + Referral Links must remain placeholders; the DelegatedCases page is functionally correct but mislabelled if SD intends "facilitator-originated cases" semantics.
5.5 Page 5 — Commissions.tsx (route: /commissions, FF-gated, default OFF)¶
Backend dependency (D4 + D13): no commission ledger model, no endpoint, no service. Confirmed by grep -li commission app/ — only app/seed_graph.py (Neo4j relationship label, not application logic). AND no source-attribution layer (§5.4b / D13) — without it, commissions can't be tied to specific facilitators.
Spec for this page is to render a placeholder card behind feature flag facilitator_commissions_enabled (must be added to config/feature_flags.yaml per .claude/rules/coding-principles.md "Feature Flags Everywhere"). Copy:
"Commission tracking is coming in Phase 4. We'll show your earned commissions, payout schedule, and historical statements here once it's ready."
Backend work required to lift the placeholder (not in this PR's scope — file as a separate spec):
- Commission model with: id, tenant_id, facilitator_id, case_id, amount_cents (USD per CLAUDE.md "All money in USD cents"), currency_code, status (pending, accrued, paid), accrued_at, paid_at
- GET /api/v1/facilitator/commissions — permission commission:read:own (new perm, must be granted to facilitator role)
- GET /api/v1/facilitator/commissions/summary — totals by status
- Cascade rule for case-cancellation → commission reversal
- Connection to existing FacilitatorPartnership.commission_pct (already in schema per ADR-0018 line 253)
5.6 Page 6 — ReferralLinks.tsx (route: /referrals, FF-gated, default OFF)¶
Backend dependency (D5): no referral_link table, no public attribution endpoint. Same placeholder pattern as Commissions, behind FF facilitator_referral_links_enabled.
Backend work required (separate spec):
- ReferralLink model: id, tenant_id, facilitator_id, slug (URL-safe unique), landing_path, utm_source/utm_medium/utm_campaign, is_active, created_at, expires_at
- GET /api/v1/facilitator/referral-links — list own
- POST /api/v1/facilitator/referral-links — create
- PATCH /api/v1/facilitator/referral-links/{id} — toggle active
- Public marketing-side: GET /r/{slug} redirects to /?ref=<id> with attribution cookie (1-year TTL) → patient-app reads cookie at signup and sets Patient.referral_facilitator_id (new column)
6. Tenant + Auth¶
6.1 Tenant resolution path¶
- User signs into
facilitator.curaway.aiwith their Clerk credentials. - Clerk returns active
organizationviauseOrganization()(apps/admin-app/src/lib/api-client.ts:18). PortalOrgGate(portal="facilitator")(packages/shared-web/src/components/auth/PortalOrgGate.tsx:159-198) checksorganization.publicMetadata.portal_type === "facilitator":- Match: render children
- No match / no active org / multiple orgs: show filtered org-picker filtered to memberships where
publicMetadata.portal_type === "facilitator" - No facilitator memberships at all:
<NoAccessPanel portal="facilitator">(line 70) — copy already says "Your account isn't a member of any facilitator organization." - Once the gate passes,
useTenantId()callsresolveTenantId(organization.id, publicMetadata): - First tries
publicMetadata.tenant_id(preferred — admin sets it during onboarding). - Falls back to runtime org→tenant map (static seed in
packages/shared-core/src/tenant-map.ts:40-52plus dynamic fetch fromGET /api/v1/public/tenant-map). ApiClientis constructed with the resolved tenant and starts attachingX-Tenant-ID: <resolved>to every request.
6.2 Tenant naming (D1)¶
Working name: tenant-curaway-facilitators (matches the prefix pattern tenant-curaway-{ops|patients} used for other shared-fleet tenants — see packages/shared-core/src/tenant-map.ts:46, tenant-map.ts:65 and rbac_middleware.py:63).
Why a single shared facilitator tenant (mirrors ADR-0018 row 20: "1 shared, row-level by facilitator_id"):
- Consistent with the model: facilitators are not tenant-isolated from each other (they share clinical context with their patients via CaseShare, not their own data silos).
- Simplifies seeding — one row in tenants, one mapping in tenant_org_mappings, one Clerk org.
- Matches the existing pattern for tenant-curaway-ops (coordinators).
SD must confirm name (D1).
6.3 Clerk org provisioning¶
- Platform admin creates a Clerk organization "Curaway Facilitators" (one org, all facilitators are members).
- Sets
publicMetadata: - Adds row to
tenant_org_mappings:(org_id, tenant_id, portal_type) = (org_<clerk>, "tenant-curaway-facilitators", "facilitator"). - Invites individual facilitators by email — Clerk sends invite, facilitator signs up, Clerk auto-joins them to the org.
- Backend RBACMiddleware (
app/middleware/rbac_middleware.py:83-141) resolves the org → tenant on every request. Thefacilitatorrole + the 3 portal permissions (consent:facilitator:grant/list/revoke) +case:read:delegatedare already seeded inrolestable per migration824b672ec7a4(lines 100-103) andcab789eed1ca.
6.4 Invite-only signup¶
The Auth.tsx page must include both Sign In and Sign Up tabs (consistent with admin/coordinator/mso/provider per apps/admin-app/src/pages/Auth.tsx:78-114), but signup without a Clerk org invite produces a user with zero org memberships, which PortalOrgGate already handles via <NoAccessPanel/> ("ask a Curaway admin to add you").
Copy adjustments for facilitator Auth page:
- Sign-in headline: "Facilitator Portal" (replaces admin's "Ops Dashboard")
- Sign-up headline: "Request Facilitator Access" (mirrors admin's "Request Access")
- Branding panel feature bullets:
- "Track patient delegations in one place"
- "Earn commission on completed referrals" (only if D4 unblocked)
- "View patient ratings & feedback"
- "Generate trackable referral links" (only if D5 unblocked)
- Right panel uses Coral accent dots on Deep Ocean background — same as admin (apps/admin-app/src/pages/Auth.tsx:118-146).
6.5 JWT claim handling¶
Standard Clerk JWT — RBACMiddleware already pulls org_id, org_role, user_id (sub) and resolves permissions. No new claims needed. The frontend never inspects JWT contents — it forwards the bearer token via ApiClient.headers().
6.6 Facilitator hasn't been onboarded yet¶
Two cases:
- No Clerk account: the user lands on /auth?tab=signup, signs up — Clerk creates the user but with no org memberships. PortalOrgGate shows <NoAccessPanel portal="facilitator"/> ("Ask a Curaway admin to add you"). Resolution: SD/admin manually invites them via Clerk dashboard.
- Clerk account exists, no facilitator org membership: same NoAccessPanel outcome.
7. Permissions Matrix¶
Permissions resolved from @require_permission(...) decorators in app/routers/facilitator_portal.py cross-referenced with the 824b672ec7a4 grant migration:
| API call | Permission required | Granted to (role) | Granted in (file:line) | Used by (page) |
|---|---|---|---|---|
POST /consent/facilitator/grant |
consent:facilitator:grant |
facilitator |
alembic/versions/824b672ec7a4_grant_portal_permissions_to_roles.py:101 |
(none — patient-app concern; listed for completeness) |
POST /consent/facilitator/revoke |
consent:facilitator:revoke |
facilitator |
824b672ec7a4_grant_portal_permissions_to_roles.py:103 |
(none — patient-app) |
GET /consent/facilitator/list |
consent:facilitator:list |
facilitator |
824b672ec7a4_grant_portal_permissions_to_roles.py:102 |
(none — patient-app) |
GET /facilitator/delegated-cases |
case:read:delegated |
facilitator (via seed) |
config/rbac_defaults.yaml:21, alembic/versions/cab789eed1ca_add_phase0_rbac_and_case_shares.py:38 |
DelegatedCases, DelegationRequests, CaseDetail |
POST /ratings/submit |
rating:submit |
patient (and coordinator for peer) |
824b672ec7a4_grant_portal_permissions_to_roles.py:107, :97 |
(none — patient-app) |
GET /coordinator/{id}/performance |
coordinator:performance:read |
coordinator, platform_admin, super_admin |
824b672ec7a4_grant_portal_permissions_to_roles.py:91, 111-112 |
Ratings — but facilitator is NOT granted this perm (D3 dependency) |
Per-resource authorization gap: case_access.py:115 notes that facilitators are one of the "actor_types that reach case [...]", but the comment continues that they require special exemption handling. The current /facilitator/delegated-cases endpoint does not use Depends(require_case_access) — it filters by actor_id == request.state.user_id in the service (facilitator_consent_service.py:224-228). Adding a /facilitator/delegated-cases/{case_id} detail endpoint would need either an exemption in tests/test_route_access_scanner.py or case_access middleware support for facilitators (which isn't there today). This is why §5.3 CaseDetail filters client-side instead of round-tripping for detail.
Missing tenant_id filter — backend pre-req (BLOCKING): facilitator_consent_service.py:219-232 accepts a tenant_id parameter on get_delegated_cases but the SQL WHERE clause filters only on actor_id, actor_type, consent_granted, is_active — tenant_id is never used. Today this is dormant because Clerk user_id is globally unique, but it's a real per-resource auth gap: the row read isn't tenant-scoped, only actor-scoped. Must be filed as a backend ticket and fixed before §11.4 end-to-end loop validation, otherwise a misconfigured actor_id (or a future-spec where actor IDs aren't globally unique, e.g. external partner orgs) reads across tenants. See §8 EC-23 and §10 D12 (new).
8. Edge Cases (mandatory per .claude/rules/coding-principles.md)¶
| ID | Scenario | Expected behavior | Verified by |
|---|---|---|---|
| EC-1 | Facilitator's only org membership has publicMetadata.portal_type ≠ "facilitator" |
PortalOrgGate renders NoAccessPanel (already handled at PortalOrgGate.tsx:117-118) |
Manual + Vitest |
| EC-2 | Facilitator has multiple facilitator-portal orgs (rare — multi-agency role) | PortalOrgGate renders the filtered picker (already handled) |
Manual |
| EC-3 | First page-load before loadDynamicTenantMap() resolves and Clerk org has no publicMetadata.tenant_id |
useTenantId() returns '' momentarily → ApiClient omits X-Tenant-ID header → backend returns 403 → ApiErrorBoundary redirects to /auth |
Test: load with mocked slow /public/tenant-map. Mitigation: ensure facilitator org always has publicMetadata.tenant_id set at provisioning so we never depend on the dynamic fetch. |
| EC-4 | facilitator_consent_enabled flag flipped off mid-session for the tenant |
Subsequent requests return 403 with error_code=FEATURE_DISABLED → ApiErrorBoundary.tsx:52-61 renders friendly "Not yet available" panel |
Manual test by toggling Flagsmith |
| EC-5 | Patient revokes consent while facilitator is on CaseDetail page | Next useApiQuery retry filters the case out → CaseDetail's "case no longer in result set" copy fires |
Manual: 2-window test |
| EC-6 | RBAC role facilitator removed from Clerk org membership while user has the page open |
Next API call returns 403 AUTH_PERMISSION_DENIED → ApiErrorBoundary redirects to /auth |
Manual |
| EC-7 | Network timeout (≥10s, no response) | fetch rejects → useApiQuery sets error → ApiErrorBoundary shows "Failed to load data" with Retry |
Manual offline test |
| EC-8 | API returns 500 / 502 | Same as EC-7 — generic error pane with Retry | Manual + Playwright |
| EC-9 | Empty result set (new facilitator with zero delegations) | Empty state message renders, NOT a crash | Vitest with mocked empty payload |
| EC-10 | API returns malformed envelope (e.g. data: null) |
ApiClient.unwrap() returns null → page treats as empty state |
Vitest |
| EC-11 | Stale Clerk JWT (expired) | getToken() from useAuth auto-refreshes; if refresh fails, request 401 → ApiErrorBoundary redirects to /auth |
Manual |
| EC-12 | Two browser tabs open, one revokes via Clerk session, the other still tries to call API | 401 on next request → redirect to /auth |
Manual |
| EC-13 | Same case_id appears twice in response (data quality bug) |
List render uses share_id as React key — no duplicate-key warning. UI shows two rows. |
Code review (use share_id not case_id for keys) |
| EC-14 | created_at is null (DB allows it per schema created_at: datetime \| None) |
Render "—" or "Unknown" — never throw on new Date(null) |
Vitest |
| EC-15 | source_tenant_id is unfamiliar string (cross-tenant share, e.g. patient-app tenant) |
Display as opaque tenant ID — do not attempt to resolve to a human name (PII risk) | Code review |
| EC-16 | Facilitator clicks Settings → SettingsPage gets portal="facilitator" but the shared component wasn't tested with this value |
Pre-flight verification: check packages/shared-web/src/components/settings/SettingsPage.tsx accepts 'facilitator' as a valid portal prop. If not, add it as a one-line change. |
TBD — verify in implementation |
| EC-17 | Facilitator-app deployed before tenant-curaway-facilitators is seeded in tenants + tenant_org_mappings |
Every request 403s on tenant validation → users locked out. Mitigation: §11 rollout step 1 must be the seed migration before any DNS cutover. | Migration required first |
| EC-18 | User signs up via /auth?tab=signup without an invite, lands on NoAccessPanel, but signs up first then asks SD to invite — second login shows correct portal |
Confirmed flow already works (PortalOrgGate re-evaluates on every render) | Manual |
| EC-19 | Multi-tab race: facilitator clicks Retry in tab A while tab B's request also retries | Both run concurrently — last write wins. useApiQuery cancellation token guards stale state. |
Code already uses cancelled flag (useApiQuery.ts:24-42) |
| EC-20 | Clock skew > 60s between client and server breaks Clerk JWT validity | Clerk SDK auto-refreshes; on persistent failure, normal 401 → /auth flow | Manual |
| EC-21 | Facilitator user is a member of two facilitator-portal orgs across different tenants | PortalOrgGate shows the org-picker filtered to facilitator memberships; user picks one per session; switching requires explicit re-pick. No implicit tenant blending. |
Manual + Vitest |
| EC-22 | CaseShare.is_active=false (revoked) vs row deleted entirely |
Service filters on is_active=True (facilitator_consent_service.py:228) — both behave the same to the facilitator UI. Backend policy: prefer soft-delete (is_active=false) so audit trail is preserved. Hard-delete is reserved for GDPR Article 17 erasure. |
Code review |
| EC-23 | Cross-tenant actor_id collision in get_delegated_cases (no tenant_id filter on SQL) |
Today: dormant because Clerk user_ids are globally unique. After: BLOCKING fix per §10 D12 + §7 backend pre-req. UI does not need a separate edge case once the filter is added. | Backend test must assert tenant_id filter |
9. Validation Plan¶
9.1 Manual smoke tests (after first deploy, run these in order)¶
- Hit
https://facilitator.curaway.ai/while signed-out → redirects to/auth?tab=signin. - Sign in with a facilitator-org-member account → lands on
/cases. - With a facilitator account that has zero delegations → empty state shows.
- Seed a
CaseSharerow directly via psql (or invite a patient to grant) → refresh → row appears in list. - Click row →
/cases/:caseIdrenders share metadata. - Toggle
facilitator_consent_enabledto false in Flagsmith fortenant-curaway-facilitators→ refresh → "Not yet available" panel. - Toggle back on → list reappears.
- Sign out via UserButton → returns to
/auth. - Mobile viewport (375px) — verify all 5 pages render without horizontal scroll.
- Tab through nav with keyboard — focus rings are visible (Teal #008B8B).
9.2 Vitest unit/component tests¶
App.test.tsx— renders Clerk-key-missing fallback whenVITE_CLERK_PUBLISHABLE_KEYis empty (mirrorsapps/admin-app/src/App.test.tsx)pages/DelegatedCases.test.tsx— empty state, error state, success state, sort, retrypages/CaseDetail.test.tsx— "case no longer shared" path, copy-id buttonpages/Ratings.test.tsx— placeholder when FF off; full layout when FF on (skip until D3)services/facilitatorApi.test.ts— service constructor, getDelegatedCases happy path + error mappingcomponents/FacilitatorLayout.test.tsx— nav active states, UserButton present
9.3 Playwright E2E (add file e2e/facilitator-portal.spec.ts)¶
Matches the existing e2e/coordinator-api.spec.ts shape. Required env: E2E_FACILITATOR_URL (add to playwright.config.ts:11 projects array), E2E_API_URL, auth state file at e2e/.auth/facilitator.json.
Test cases:
1. Login flow — navigate to /, redirected to /auth, sign in via storage state, land on /cases.
2. Empty state E2E — facilitator with no shares sees the empty copy.
3. Happy-path delegations — list returns ≥1 row, click → CaseDetail renders.
4. Permission-denied — temporarily strip case:read:delegated (test fixture), expect 403 → /auth redirect.
5. Multi-portal smoke — same Clerk user signed in to mso.curaway.ai cannot view facilitator portal (PortalOrgGate blocks).
9.4 Code-review subagent gates (per .claude/rules/definition-of-done.md)¶
/code-review— spec compliance + code quality (Tier 3 mandatory)/impeccable audit— accessibility + responsive + theming/polish— final visual pass- Architecture review by
architecture-reviewer.mdsubagent (Tier 3 feature)
10. Decision Points for SD¶
| ID | Decision | Default proposed | Why this matters |
|---|---|---|---|
| D1 | Tenant name | tenant-curaway-facilitators |
Single shared tenant matches ADR-0018 row 20. Affects every request header, RBAC seed data, Vercel env. Must be set BEFORE cutover. |
| D2 | "Patient delegation requests inbox" — does it mean active shares only, or do we add a backend concept of pending invitations? | Ship as "All active delegations" only. Defer pending-invitation flow. | The current model has no pending state — consent_granted is binary. Adding pending requires backend schema + workflow change. Either accept the simpler v1 framing, or file a separate spec for the pending-invitation feature. |
| D3 | Ratings page — reuse /coordinator/{id}/performance (rename + grant perm) or hide behind FF? |
Hide behind FF facilitator_ratings_enabled (default off) for v1. |
Reusing requires backend rename + perm grant + endpoint contract change. Cleaner to ship the empty placeholder and follow up with a focused backend ticket. |
| D4 | Commission tracking — backend dependency? | Yes, blocks page rendering. Render placeholder card behind FF until backend ships. | No model, no router, no service. ADR-0018 mentions FacilitatorPartnership.commission_pct only. Phase 4 work per multi-tenancy-phase3-coordinators-facilitators-impl.md:50. |
| D5 | Referral link management — backend dependency? | Yes. Same placeholder pattern. | No table, no attribution flow today. Phase 4. |
| D6 | Vite dev port for facilitator-app |
8085 — next free port (patient=8080, admin=8081, coordinator=8081, provider=8082, design-system=8083, mso=8084). Demote to "convention, not a real DP" once committed. | Avoid collisions when running multiple portals locally. |
| D7 | Vercel project name | curaway-facilitator (matches curaway-{admin,coordinator,mso,provider}) |
Affects DNS, env-var groups, deploy hooks. |
| D8 | "Contact coordinator" / messaging from CaseDetail | Defer to v2. Show case_id as plain copy field. | No facilitator-coordinator messaging surface exists. |
| D9 | Should facilitator-app be feature-flagged behind a top-level facilitator_portal_enabled flag (so we can dark-launch the URL)? |
Yes — gate the entire app's main routes behind facilitator_portal_enabled (default off), flip to on per-tenant for soft rollout. |
Parallel to how other portals shipped. Aligns with ground rule "Feature Flags Everywhere." |
| D10 | Is the existing facilitator_consent_enabled flag the right gate for the entire portal, or do we want a separate facilitator_portal_enabled? |
Separate. Consent flag governs the API endpoint family; portal flag governs whether the SPA's primary nav is reachable. Different lifecycle. | Different concerns; mixing them couples consent rollout to UI rollout. |
| D11 | Should the patient-app's existing facilitator-consent grant flow be exposed as a UI today, or wait? | Out of scope for this spec — but flag for SD: today's /consent/facilitator/grant endpoint has no caller in the patient-app codebase (grep confirms). Patient-app must wire this for the loop to be testable end-to-end. |
Pre-req for E2E validation step §11.4 |
| D12 | Backend pre-req (BLOCKING): add tenant_id filter to FacilitatorConsentService.get_delegated_cases SQL (facilitator_consent_service.py:219-232). Today the parameter is accepted but unused. |
Yes, file as backend ticket and ship before §11.4. | Per-resource auth gap — dormant today (Clerk user_ids globally unique) but real. Must close before any expansion to non-Clerk actor IDs. |
| D13 | Phase 4 BLOCKING for D4 + D5: add facilitator attribution layer — facilitators table + Patient.referred_by_facilitator_id FK (+ optional denormalized Case.referred_by_facilitator_id). |
File as separate backend spec. Sequencing: D13 lands first → D5 (referral links) lands → D4 (commissions) lands last. | Audit (§5.4b) confirmed there is NO mechanism today to identify facilitator-sourced vs direct cases. Patient.referral_source is free-text only; CaseShare is post-hoc delegation, not source. Without D13 the commission flow has no foundation. |
11. Implementation Tier Breakdown (Opus / Sonnet / Haiku)¶
Per .claude/rules/definition-of-done.md "Model Tier for Tasks":
Opus (architecture, design, judgment, security boundaries)¶
- This spec itself
- ADR amendment if D2/D3 forces backend changes
- The facilitator branding panel copy on
Auth.tsx(clinical-adjacent, brand voice) App.tsxClerkProvider + PortalOrgGate wiring (auth boundary)lib/api-client.ts(security boundary — Clerk JWT + tenant header)- Decision on whether to ship Ratings/Commissions/Referrals as placeholders vs. hidden routes
- Code review for all Tier-3 PRs
Sonnet (implementation from clear spec)¶
- All five page components (
DelegatedCases,DelegationRequests,CaseDetail,Ratingsplaceholder,Commissionsplaceholder,ReferralLinksplaceholder) services/facilitatorApi.ts— copycoordinator-appshape, replace endpointshooks/useFacilitatorApi.ts— 3-line wrappercomponents/FacilitatorLayout.tsx— copyCoordinatorLayout, swap nav itemsAuth.tsxpage — copy admin's, change feature copy- Vitest unit tests
- Playwright E2E spec
Sonnet or Haiku (mechanical / config)¶
package.json,tsconfig.json,tailwind.config.ts,postcss.config.js,vite.config.ts,vercel.json,index.html,index.css— copy and rename.env.exampleplaywright.config.tsaddfacilitatorproject- Add
facilitator_portal_enabledandfacilitator_ratings_enabledandfacilitator_commissions_enabledandfacilitator_referral_links_enabledflags toconfig/feature_flags.yaml(per.claude/rules"Configuration Externalization")
Recommended PR sequencing¶
- PR 1 (Opus) — this spec merged + decisions resolved (D1–D11).
- PR 2 (Sonnet) — scaffold
apps/facilitator-app/(config files only, blank App.tsx, Auth route stub) so Vercel can deploy a green build before any page is wired. - PR 3 (Sonnet) — wire ApiClient + DelegatedCases page + tests.
- PR 4 (Sonnet) — Auth page polish + FacilitatorLayout + CaseDetail.
- PR 5 (Sonnet) — placeholder pages for Ratings/Commissions/Referrals behind FFs.
- PR 6 (Opus, if greenlit) — backend tickets for D3/D4/D5 then a follow-up frontend PR to lift placeholders.
12. Migration / Rollout¶
12.1 Pre-cutover (backend, no UI exposure)¶
- Seed tenant — Alembic migration adds
tenant-curaway-facilitatorstotenantstable. Reversible. - Seed
tenant_org_mappings— same migration adds(org_<clerk_facilitator>, "tenant-curaway-facilitators", "facilitator"). Generate the org in Clerk first; capture theorg_<...>id. - Verify role seed —
roles[code='facilitator']already exists (config/rbac_defaults.yaml:17-23); permissions already granted by migration824b672ec7a4. No-op verification only. - Add feature flags —
config/feature_flags.yaml+ Flagsmith cloud: facilitator_portal_enableddefaultfalsefacilitator_ratings_enableddefaultfalsefacilitator_commissions_enableddefaultfalsefacilitator_referral_links_enableddefaultfalseRunscripts/sync_flagsmith.py --dry-runper.claude/rules/definition-of-done.md"Configuration Externalization."
12.2 Frontend deploy¶
- Land PR 2 (scaffold) → Vercel auto-deploys to a preview URL → verify build succeeds.
- Once PR 3–5 merge,
vercel --prodfromapps/facilitator-app/(Vercel CLI). Project:curaway-facilitator. Mapfacilitator.curaway.aito the project. - DNS: add CNAME
facilitator.curaway.ai → cname.vercel-dns.com(existing Cloudflare zone, same pattern as other portals).
12.3 First facilitator (validation user)¶
- SD invites their own
gmail.com(srikanth.donthi@gmail.com) to the Clerk facilitator org as a member with roleorg:admin. - Visit
https://facilitator.curaway.ai/→ sign in → confirm PortalOrgGate passes → empty state on/cases. - Toggle
facilitator_portal_enabledto true fortenant-curaway-facilitatorsonly.
12.4 End-to-end loop verification¶
- From the patient-app dev console (or via
curluntil D11 wires the UI), callPOST /api/v1/consent/facilitator/grantwithcase_id+ SD's facilitator user_id → returns 200. - Refresh
https://facilitator.curaway.ai/cases→ row appears with the granted share. - From patient-app, call
/consent/facilitator/revoke→ row disappears on next refresh.
12.5 Open to others¶
- Invite 1 external partner facilitator. Ask them to repeat steps 9–12.
- Once green, flip
facilitator_portal_enabledfortenant-curaway-facilitatorsto true permanently and remove the soft-launch landing-page banner (if any).
12.5b CASE_ACCESS_EXEMPT scanner gate¶
Any future facilitator route containing {case_id} (e.g. D8 messaging or a richer /facilitator/cases/{id} detail endpoint) MUST either:
1. Use Depends(require_case_access) after case_access.py is extended for facilitator semantics, OR
2. Be added to CASE_ACCESS_EXEMPT in tests/test_route_access_scanner.py with a tracking-issue link explaining why.
The CI scanner will fail otherwise. Spec sequencing in §11 PR-3/4/5 must verify this on every route addition.
12.6 Rollback plan¶
- Frontend break: revert the Vercel deploy via
vercel rollback. - Backend break: flip
facilitator_portal_enabledto false → app shows feature-disabled fallback. Or flipfacilitator_consent_enabledfor hard-disable of the API endpoint set. - Data corruption: Alembic downgrade for the seed migration is symmetric (per
824b672ec7a4_grant_portal_permissions_to_roles.py:130-141pattern).
13. Out of Scope / Future Work¶
| Item | Why deferred | Tracking |
|---|---|---|
| Commission ledger model + endpoints | Backend dependency D4. Phase 4 per multi-tenancy-phase3-coordinators-facilitators-impl.md:50. |
New issue — file before PR 5 |
| Referral link model + attribution flow | Backend dependency D5. Phase 4. | New issue — file before PR 5 |
| Facilitator-side ratings GET endpoint | Backend dependency D3. Either rename /coordinator/{id}/performance to actor-agnostic or add /facilitator/{id}/ratings. |
New issue |
| Pending-invitation concept (real "inbox") | No data model today. Requires CaseShare.status = pending|granted|revoked + email invite trigger. |
New spec |
| Facilitator-coordinator in-portal messaging | No surface in any portal yet (admin/MSO also lack it). | Cross-portal messaging spec |
| Facilitator's view of patient case detail beyond share metadata | case_access middleware doesn't accept facilitator role today; tests/test_route_access_scanner.py would need an exemption. |
Tied to per-resource auth roadmap (#587) |
| Facilitator org-level permissions self-service (manage their own staff) | Requires multi-user-per-facilitator-account; today every facilitator is one user. | ADR-0018 Phase 5 |
| Internationalization | English-only v1. | Standard frontend roadmap |
| Mobile-native iOS/Android | Web responsive only. | Out of scope for foreseeable future |
| Public landing page for the facilitator program (marketing) | Different surface (curaway-ai/curaway-frontend patient-app marketing routes). |
Marketing spec |
14. References¶
Backend¶
app/routers/facilitator_portal.py:1-181— full routerapp/schemas/facilitator.py:36-67— request/response schemasapp/services/facilitator_consent_service.py:1-253— service layerapp/middleware/require_permission.py:31-100— permission decoratorapp/middleware/rbac_middleware.py:83-141— org→tenant resolutionapp/middleware/case_access.py:100-125— per-resource auth (facilitator gap noted at line 115)app/routers/coordinator_portal.py:657-733— ratings + performance (cross-domain reference)app/services/rating_service.py:80-108— rating aggregation logicalembic/versions/824b672ec7a4_grant_portal_permissions_to_roles.py:88-113— RBAC seedalembic/versions/cab789eed1ca_add_phase0_rbac_and_case_shares.py:38—case:read:delegatedpermconfig/rbac_defaults.yaml:17-23— facilitator role definitionconfig/feature_flags.yaml:269-272—facilitator_consent_enabled
Frontend (reference apps)¶
apps/admin-app/src/App.tsx:1-112— full ClerkProvider + PortalOrgGate patternapps/admin-app/src/main.tsx:1-11— entrypointapps/admin-app/src/pages/Auth.tsx:1-149— split-pane auth pageapps/admin-app/src/lib/api-client.ts:1-34— useApiClient + useTenantIdapps/admin-app/package.json:1-37— package shapeapps/admin-app/vercel.json:1-21— security headers + SPA rewritesapps/coordinator-app/src/App.tsx:1-91— alternate routing exampleapps/coordinator-app/src/components/CoordinatorLayout.tsx:1-50+— icon-rail layoutapps/coordinator-app/src/components/ApiErrorBoundary.tsx:1-109— full error/loading/empty patternapps/coordinator-app/src/hooks/useApiQuery.ts:1-55— generic data hookapps/coordinator-app/src/hooks/useCoordinatorApi.ts:1-25— API service hookapps/coordinator-app/src/services/coordinatorApi.ts:1-80+— service class patternapps/coordinator-app/src/pages/CaseQueue.tsx:1-535— list page reference (filters, sort, keyboard nav, empty/error states)apps/coordinator-app/src/pages/Performance.tsx:1-60+— Ratings page referenceapps/mso-app/src/App.tsx:1-86— minimal-routes exampleapps/provider-app/src/App.tsx:1-88— alternate Auth routingapps/coordinator-app/.env.example:1-3— env-var shape
Shared packages¶
packages/shared-web/src/components/auth/PortalOrgGate.tsx:33-198— portal gating (already supports'facilitator')packages/shared-core/src/api-client.ts:1-138— ApiClient + ApiError + envelope unwrappackages/shared-core/src/tenant-map.ts:40-193— resolveTenantId + dynamic fetch
Project context¶
CLAUDE.md"Brand" section (Teal/Coral/Deep Ocean, Montserrat + Crimson Pro, 8px grid, WCAG AA)docs/adr/0018-multi-tenancy-platform-architecture.md:17-22, 39-42, 250-256, 451-456— 7-actor model + facilitator tenant + commission_pct + portal scopedocs/specs/multi-tenancy-phase3-coordinators-facilitators-impl.md:48-51— "facilitator commission tracking → Phase 4" deferralplaywright.config.ts:7-10, 33-64— subdomain pattern for portals
Last updated: 2026-05-03.