diff --git a/docs/phase3-setup-checklist.md b/docs/phase3-setup-checklist.md new file mode 100644 index 0000000..8545833 --- /dev/null +++ b/docs/phase3-setup-checklist.md @@ -0,0 +1,187 @@ +# Phase 3 Setup Checklist +## Clerk + Supabase + Railway Staging + +Run these steps in order. Code is already written — you just need the keys. + +--- + +## Step 1: Clerk Account Setup + +1. Go to clerk.com and create an account +2. Create a new application — name it **Signal** +3. Select **React** as the framework +4. From **Dashboard > API Keys**, copy: + - **Publishable Key** (starts with `pk_test_...`) — goes in the frontend `.env` + - **Secret Key** (starts with `sk_test_...`) — NOT needed for our approach (we use JWKS) +5. From **Dashboard > API Keys > Show API URLs**, copy the **Frontend API URL** + - Looks like: `https://clean-mayfly-62.clerk.accounts.dev` + - Your JWKS URL is: `/.well-known/jwks.json` + - Example: `https://clean-mayfly-62.clerk.accounts.dev/.well-known/jwks.json` + - This goes in Railway as `CLERK_JWKS_URL` + +6. From **Dashboard > Configure > Organizations**, enable Organizations + - Create one organization named **Gaboro DME** with slug `gaboro-pilot` + - Copy the Organization ID (starts with `org_...`) — you'll need it for the SQL step below + - Add yourself as a member of that organization + +7. From **Dashboard > Configure > Domains**, add: + - `http://localhost:5173` (local dev) + - Your Vercel production URL when it's live + +--- + +## Step 2: Supabase Staging Project + +1. Go to supabase.com, create a new project named **signal-staging** +2. Wait for it to provision (2-3 minutes) +3. Go to **Settings > API**, copy: + - **Project URL** (looks like `https://xxxxxxxxxxxx.supabase.co`) + - **Service Role Key** — must be the long `eyJ...` JWT format (NOT the `sb_` format) +4. Go to **SQL Editor**, run the migration SQL below + +--- + +## Step 3: SQL Migration +### Run on BOTH Supabase projects (production + staging) + +```sql +-- Add Clerk org ID column to organizations table +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS clerk_org_id TEXT UNIQUE; + +-- Update the pilot org with the real Clerk org ID +-- Replace org_xxxxxxxxxxxxxxxx with the actual org ID from Clerk Dashboard +UPDATE organizations + SET clerk_org_id = 'org_xxxxxxxxxxxxxxxx' + WHERE slug = 'gaboro-pilot'; + +-- Helper function for RLS policies (reads Clerk org ID from JWT) +CREATE OR REPLACE FUNCTION requesting_owner_id() +RETURNS TEXT +LANGUAGE SQL +STABLE +AS $$ + SELECT COALESCE( + (auth.jwt() -> 'o') ->> 'id', + auth.jwt() ->> 'sub' + ); +$$; + +-- RLS on organizations +ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; +CREATE POLICY "org_members_see_own_org" + ON organizations FOR SELECT + USING (clerk_org_id = requesting_owner_id()); + +-- RLS on upload_batches +ALTER TABLE upload_batches ENABLE ROW LEVEL SECURITY; +CREATE POLICY "org_members_see_own_batches" + ON upload_batches FOR SELECT + USING (org_id IN ( + SELECT id FROM organizations WHERE clerk_org_id = requesting_owner_id() + )); +CREATE POLICY "org_members_insert_own_batches" + ON upload_batches FOR INSERT + WITH CHECK (org_id IN ( + SELECT id FROM organizations WHERE clerk_org_id = requesting_owner_id() + )); + +-- RLS on normalized_records +ALTER TABLE normalized_records ENABLE ROW LEVEL SECURITY; +CREATE POLICY "org_members_see_own_records" + ON normalized_records FOR SELECT + USING (EXISTS ( + SELECT 1 FROM upload_batches ub + JOIN organizations o ON o.id = ub.org_id + WHERE ub.id = normalized_records.batch_id + AND o.clerk_org_id = requesting_owner_id() + )); + +-- RLS on report_runs +ALTER TABLE report_runs ENABLE ROW LEVEL SECURITY; +CREATE POLICY "org_members_see_own_runs" + ON report_runs FOR SELECT + USING (org_id IN ( + SELECT id FROM organizations WHERE clerk_org_id = requesting_owner_id() + )); + +-- RLS on export_files +ALTER TABLE export_files ENABLE ROW LEVEL SECURITY; +CREATE POLICY "org_members_see_own_exports" + ON export_files FOR SELECT + USING (EXISTS ( + SELECT 1 FROM report_runs rr + JOIN organizations o ON o.id = rr.org_id + WHERE rr.id = export_files.report_run_id + AND o.clerk_org_id = requesting_owner_id() + )); + +-- NOTE: source_files and mapping_decisions are write-once audit records. +-- The backend uses the service role key for writes (bypasses RLS by design). +-- Add SELECT policies here when frontend reads are needed in a later phase. +``` + +--- + +## Step 4: Connect Supabase to Clerk (Native Integration) + +### In Clerk Dashboard: +1. Go to **Configure > Integrations** (or search "Supabase") +2. Click **Activate Supabase integration** +3. Copy your **Clerk domain** shown there + +### In Supabase Dashboard (repeat for BOTH projects): +1. Go to **Authentication > Sign In / Sign Up > Third Party Auth** +2. Click **Add provider** > select **Clerk** +3. Paste the Clerk domain +4. Save + +This makes Supabase trust Clerk JWTs — no custom templates needed. + +--- + +## Step 5: Set Railway Environment Variables + +### Production environment: +In Railway > signal-api > production > Variables, add: +``` +CLERK_JWKS_URL = https://eternal-goblin-1.clerk.accounts.dev/.well-known/jwks.json +``` + +### Staging environment: +In Railway > signal-api > staging > Variables, add: +``` +CLERK_JWKS_URL = https://eternal-goblin-1.clerk.accounts.dev/.well-known/jwks.json +SUPABASE_URL = https://.supabase.co +SUPABASE_ANON_KEY = eyJ... (anon key from staging project) +SUPABASE_SERVICE_KEY = eyJ... (service role key from staging project) +``` +Staging already has SIGNAL_API_KEY and ALLOWED_ORIGINS set. + +--- + +## Step 6: Set Frontend .env + +Edit `signal-ui/.env`: +``` +VITE_CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxx +``` + +--- + +## Step 7: Deploy Staging Backend + +Once all Railway staging vars are set, trigger a deploy: +```bash +cd /Users/sttil-solutions/projects/signal +railway up --detach --environment staging -m "Phase 3: Clerk auth + staging env" +``` + +--- + +## Staging API Key (save this) + +The staging environment uses a separate API key for direct testing: +``` +r0C6vm1RLeg84lB9jeZ5sSRieDZWsLL0dAMDfEcXubE +``` diff --git a/python-backend/api/main.py b/python-backend/api/main.py index 8122d48..ce7bd8a 100644 --- a/python-backend/api/main.py +++ b/python-backend/api/main.py @@ -5,9 +5,12 @@ import io import os import sys from datetime import date +from functools import lru_cache from pathlib import Path from typing import Optional +import jwt +from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse @@ -49,11 +52,54 @@ app.add_middleware( # API key auth — enforced when SIGNAL_API_KEY env var is set. # In dev (no env var), all requests pass. In production, X-API-Key header is required. _api_key = os.getenv("SIGNAL_API_KEY", "") +_clerk_jwks_url = os.getenv("CLERK_JWKS_URL", "") -def _require_api_key(x_api_key: str = Header(default="")) -> None: - if _api_key and x_api_key != _api_key: - raise HTTPException(status_code=401, detail="Invalid or missing API key") +@lru_cache(maxsize=1) +def _get_jwks_client() -> PyJWKClient: + if not _clerk_jwks_url: + raise RuntimeError("CLERK_JWKS_URL is not set") + return PyJWKClient(_clerk_jwks_url, cache_keys=True, cache_jwk_set=True, lifespan=300) + + +def verify_clerk_token(authorization: str) -> dict: + """Verify a Clerk Bearer JWT. Returns decoded claims or raises HTTP 401.""" + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Missing or malformed Authorization header") + token = authorization.removeprefix("Bearer ").strip() + try: + client = _get_jwks_client() + signing_key = client.get_signing_key_from_jwt(token) + return jwt.decode(token, signing_key.key, algorithms=["RS256"], + options={"verify_exp": True, "verify_nbf": True}) + except ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token has expired") + except InvalidTokenError as exc: + raise HTTPException(status_code=401, detail=f"Invalid token: {exc}") + except RuntimeError: + raise HTTPException(status_code=503, detail="Auth service not configured") + except Exception: + raise HTTPException(status_code=401, detail="Token verification failed") + + +def require_auth( + authorization: str = Header(default=""), + x_api_key: str = Header(default=""), +) -> dict: + """ + Accept either a Clerk JWT (Authorization: Bearer) or a direct API key (X-Api-Key). + Returns a claims dict. API-key path returns a synthetic service-account dict. + """ + # Fast path: API key (for direct access / testing) + if _api_key and x_api_key == _api_key: + return {"sub": "service-account", "via": "api_key"} + # Clerk JWT path (frontend / production) + if authorization.startswith("Bearer "): + return verify_clerk_token(authorization) + # Dev mode: no keys set — allow all + if not _api_key and not _clerk_jwks_url: + return {"sub": "dev", "via": "open"} + raise HTTPException(status_code=401, detail="Authentication required") DEVICE_DISPLAY = { "dexcom_g7": "Dexcom G7", @@ -186,7 +232,7 @@ def health_db(): @app.post("/api/upload", response_model=UploadResponse) async def upload_csv( file: UploadFile = File(...), - _auth: None = Depends(_require_api_key), + claims: dict = Depends(require_auth), ): if not (file.filename or "").endswith(".csv"): raise HTTPException(status_code=400, detail="File must be a .csv") @@ -217,6 +263,7 @@ async def upload_csv( log_event(AuditAction.CSV_INGEST, file.filename or "unknown", "demo_user", "success", "0.0.0.0", detail=f"{len(out)} records scored") + clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None batch_id = persist_upload( filename=file.filename or "unknown", content_bytes=content, @@ -224,6 +271,7 @@ async def upload_csv( coverage_results=results, skipped_count=len(skipped_reasons), mapping_summary=mapping_summary, + clerk_org_id=clerk_org_id, ) return UploadResponse( @@ -245,7 +293,7 @@ class ExportRequest(BaseModel): @app.post("/api/export") async def export_work_queue( body: ExportRequest, - _auth: None = Depends(_require_api_key), + claims: dict = Depends(require_auth), ): """Generate a downloadable work-queue CSV from a list of scored records.""" records = body.records diff --git a/python-backend/core/persistence.py b/python-backend/core/persistence.py index 6074e37..041d572 100644 --- a/python-backend/core/persistence.py +++ b/python-backend/core/persistence.py @@ -21,21 +21,40 @@ def _sha256(value: str) -> str: return hashlib.sha256(value.encode()).hexdigest() -def _get_or_create_org() -> str | None: - global _demo_org_id - if _demo_org_id: - return _demo_org_id - +def _get_or_create_org(clerk_org_id: str | None = None) -> str | None: + """ + Look up the Supabase org UUID. + If clerk_org_id is provided (from a verified Clerk JWT), look up by that first. + Falls back to the demo slug for API-key / dev sessions. + """ client = get_client() if not client: return None try: + if clerk_org_id: + # Production path: look up by the Clerk org ID stored on the org record + result = client.table("organizations").select("id").eq("clerk_org_id", clerk_org_id).execute() + if result.data: + return result.data[0]["id"] + # Org exists in Clerk but not yet provisioned in Supabase — create it + result = client.table("organizations").insert({ + "name": f"Org {clerk_org_id}", + "slug": clerk_org_id, + "clerk_org_id": clerk_org_id, + }).execute() + org_id = result.data[0]["id"] + logger.info(f"Auto-provisioned org for Clerk org {clerk_org_id}: {org_id}") + return org_id + + # Fallback: demo slug (API key auth or dev mode) + global _demo_org_id + if _demo_org_id: + return _demo_org_id result = client.table("organizations").select("id").eq("slug", DEMO_ORG_SLUG).execute() if result.data: _demo_org_id = result.data[0]["id"] return _demo_org_id - result = client.table("organizations").insert({ "name": "Gaboro DME — Pilot", "slug": DEMO_ORG_SLUG, @@ -56,6 +75,7 @@ def persist_upload( coverage_results: list, skipped_count: int, mapping_summary: dict, + clerk_org_id: str | None = None, ) -> str | None: """ Persist one upload batch and all related records to Supabase. @@ -65,7 +85,7 @@ def persist_upload( if not client: return None - org_id = _get_or_create_org() + org_id = _get_or_create_org(clerk_org_id=clerk_org_id) if not org_id: return None diff --git a/requirements.txt b/requirements.txt index 93ee358..d1ddb1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]>=0.29.0 python-multipart>=0.0.9 pydantic>=2.0.0 supabase>=2.0.0 +PyJWT[cryptography]>=2.8.0 diff --git a/signal-ui/package.json b/signal-ui/package.json index c0eeb23..e32fc9f 100644 --- a/signal-ui/package.json +++ b/signal-ui/package.json @@ -10,9 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "@clerk/react": "^6.7.2", "react": "^19.2.6", "react-dom": "^19.2.6" }, + "pnpm": { + "ignoredBuiltDependencies": ["@clerk/shared"] + }, "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.0", diff --git a/signal-ui/pnpm-lock.yaml b/signal-ui/pnpm-lock.yaml index b5679fe..85bef78 100644 --- a/signal-ui/pnpm-lock.yaml +++ b/signal-ui/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@clerk/react': + specifier: ^6.7.2 + version: 6.7.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: specifier: ^19.2.6 version: 19.2.6 @@ -118,6 +121,25 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@clerk/react@6.7.2': + resolution: {integrity: sha512-tgRWWTfW+ejXj6WiZqx7iMbtvh45h0445uEvbSNVCyJBEOaBlCF0/ZfAfXlWAQNbRZ+bPIphEfNUmatadZgGOQ==} + engines: {node: '>=20.9.0'} + peerDependencies: + react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + + '@clerk/shared@4.14.0': + resolution: {integrity: sha512-StZCFJ2rg0ITE3fYjIN9CKuKq8ZACW6l2+5qGCFPPYd4hZphA+VDselmh/lHPsF1ex/WRVUSHvquykAHR8cQbQ==} + engines: {node: '>=20.9.0'} + peerDependencies: + react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -403,6 +425,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.100.14': + resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -492,6 +517,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -610,6 +639,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + globals@17.6.0: resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} engines: {node: '>=18'} @@ -646,6 +678,10 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-cookie@3.0.7: + resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} + engines: {node: '>=20'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -850,6 +886,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} @@ -1047,6 +1086,24 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@clerk/react@6.7.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@clerk/shared': 4.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + tslib: 2.8.1 + + '@clerk/shared@4.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.100.14 + dequal: 2.0.3 + glob-to-regexp: 0.4.1 + js-cookie: 3.0.7 + std-env: 3.10.0 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1260,6 +1317,8 @@ snapshots: tailwindcss: 4.3.0 vite: 8.0.14(jiti@2.7.0) + '@tanstack/query-core@5.100.14': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -1331,6 +1390,8 @@ snapshots: deep-is@0.1.4: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} electron-to-chromium@1.5.361: {} @@ -1460,6 +1521,8 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + globals@17.6.0: {} graceful-fs@4.2.11: {} @@ -1484,6 +1547,8 @@ snapshots: jiti@2.7.0: {} + js-cookie@3.0.7: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} @@ -1653,6 +1718,8 @@ snapshots: source-map-js@1.2.1: {} + std-env@3.10.0: {} + tailwindcss@4.3.0: {} tapable@2.3.3: {} @@ -1662,8 +1729,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} type-check@0.4.0: dependencies: diff --git a/signal-ui/pnpm-workspace.yaml b/signal-ui/pnpm-workspace.yaml new file mode 100644 index 0000000..d2a1a1f --- /dev/null +++ b/signal-ui/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + '@clerk/shared': set this to true or false diff --git a/signal-ui/src/App.jsx b/signal-ui/src/App.jsx index 21777ca..5e503b8 100644 --- a/signal-ui/src/App.jsx +++ b/signal-ui/src/App.jsx @@ -1,4 +1,5 @@ import { useState, useCallback, useRef } from "react"; +import { SignIn, UserButton, useAuth } from "@clerk/react"; import { ThemeProvider } from "./ThemeProvider"; import Sidebar from "./components/Sidebar"; import StatCard from "./components/StatCard"; @@ -12,7 +13,9 @@ import { uploadToBackend, apiRecordToLocal } from "./lib/api"; import { parseCSV, processBatch } from "./lib/coverage"; function AppInner() { + const { getToken } = useAuth(); const [records, setRecords] = useState([]); + const [batchId, setBatchId] = useState(null); const [activeFilter, setActiveFilter] = useState("all"); const [importLabel, setImportLabel] = useState("No data imported"); const csvImportRef = useRef(null); @@ -23,38 +26,41 @@ function AppInner() { const okCount = records.filter((r) => r.flag === "OK").length; const urgent = ooc + visitDue; - const handleResults = useCallback((file) => { + const handleResults = useCallback(async (file) => { const label = new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); - uploadToBackend(file).then((data) => { - if (data) { - const results = data.records.map(apiRecordToLocal); - setRecords(results); - setImportLabel(`${file.name} · ${label} · via Signal API`); - let msg = `Loaded ${data.total} patient${data.total !== 1 ? "s" : ""} from ${file.name}`; - if (data.skipped) msg += ` · ${data.skipped} skipped`; - showToast(msg); - return; - } + const token = await getToken().catch(() => null); + const data = await uploadToBackend(file, token); - // Backend unreachable — process locally - const reader = new FileReader(); - reader.onload = (e) => { - const rows = parseCSV(e.target.result); - const { results, skipped } = processBatch(rows); - setRecords(results); - setImportLabel(`${file.name} · ${label} · local processing`); - let msg = `Loaded ${results.length} patient${results.length !== 1 ? "s" : ""} from ${file.name}`; - if (skipped.length) msg += ` · ${skipped.length} skipped`; - showToast(msg); - }; - reader.readAsText(file); - }); - }, []); + if (data) { + const results = data.records.map(apiRecordToLocal); + setRecords(results); + setBatchId(data.batch_id || null); + setImportLabel(`${file.name} · ${label} · via Signal API`); + let msg = `Loaded ${data.total} patient${data.total !== 1 ? "s" : ""} from ${file.name}`; + if (data.skipped) msg += ` · ${data.skipped} skipped`; + showToast(msg); + return; + } + + // Backend unreachable — process locally + setBatchId(null); + const reader = new FileReader(); + reader.onload = (e) => { + const rows = parseCSV(e.target.result); + const { results, skipped } = processBatch(rows); + setRecords(results); + setImportLabel(`${file.name} · ${label} · local processing`); + let msg = `Loaded ${results.length} patient${results.length !== 1 ? "s" : ""} from ${file.name}`; + if (skipped.length) msg += ` · ${skipped.length} skipped`; + showToast(msg); + }; + reader.readAsText(file); + }, [getToken]); return (
@@ -88,7 +94,8 @@ function AppInner() {
- + +
@@ -129,6 +136,20 @@ function AppInner() { } export default function App() { + const { isSignedIn, isLoaded } = useAuth(); + + if (!isLoaded) { + return
; + } + + if (!isSignedIn) { + return ( +
+ +
+ ); + } + return ( diff --git a/signal-ui/src/components/CSVExport.jsx b/signal-ui/src/components/CSVExport.jsx index bc16cd4..284c992 100644 --- a/signal-ui/src/components/CSVExport.jsx +++ b/signal-ui/src/components/CSVExport.jsx @@ -1,50 +1,48 @@ +import { useAuth } from "@clerk/react"; +import { exportFromBackend } from "../lib/api"; import { getFlagLabel, getFlagAction, getDeviceDisplay } from "../lib/coverage"; -export default function CSVExport({ records }) { - const today = new Date().toISOString().slice(0, 10); +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} - const exportCSV = () => { +export default function CSVExport({ records, batchId }) { + const { getToken } = useAuth(); + const today = new Date().toISOString().slice(0, 10); + const filename = `signal-work-queue-${today}.csv`; + + const exportCSV = async () => { if (!records || records.length === 0) return; + if (batchId) { + const token = await getToken().catch(() => null); + const blob = await exportFromBackend(records, batchId, token); + if (blob) { + downloadBlob(blob, filename); + return; + } + } + + // Fallback: client-side CSV (no batchId or backend unavailable) const headers = [ - "Patient ID", - "Device", - "Payer", - "Status", - "Priority Score", - "Days Until Resupply End", - "Recommended Action", - "Resupply End Date", - "Reason", + "Patient ID", "Device", "Payer", "Status", "Priority Score", + "Days Until Resupply End", "Recommended Action", "Resupply End Date", "Reason", ]; - const rows = records.map((r) => [ - r.patient_id, - getDeviceDisplay(r.device_type), - r.payer, - getFlagLabel(r.flag), - r.priority, - r.daysUntilEnd, - getFlagAction(r.flag), - r.coverageEndDate || "", - r.reason || "", + r.patient_id, getDeviceDisplay(r.device_type), r.payer, getFlagLabel(r.flag), + r.priority, r.daysUntilEnd, getFlagAction(r.flag), r.coverageEndDate || "", r.reason || "", ]); - const csv = [headers, ...rows] - .map((row) => - row.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",") - ) + .map((row) => row.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",")) .join("\r\n"); - - const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `signal-work-queue-${today}.csv`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8;" }), filename); }; return ( diff --git a/signal-ui/src/lib/api.js b/signal-ui/src/lib/api.js index dea68e4..06ffa30 100644 --- a/signal-ui/src/lib/api.js +++ b/signal-ui/src/lib/api.js @@ -3,7 +3,8 @@ * app can fall back to client-side coverage calculation. */ -const BACKEND_URL = "https://signal-api-production-91c2.up.railway.app"; +const BACKEND_URL = import.meta.env.VITE_SIGNAL_BACKEND_URL || + "https://signal-api-production-91c2.up.railway.app"; const API_KEY = import.meta.env.VITE_SIGNAL_API_KEY || ""; /** @@ -11,13 +12,16 @@ const API_KEY = import.meta.env.VITE_SIGNAL_API_KEY || ""; * @param {File} file * @returns {Promise} API response or null on failure */ -export async function uploadToBackend(file) { +export async function uploadToBackend(file, token = null) { const formData = new FormData(); formData.append("file", file); + const authHeader = token + ? { "Authorization": `Bearer ${token}` } + : API_KEY ? { "X-API-Key": API_KEY } : {}; try { const resp = await fetch(`${BACKEND_URL}/api/upload`, { method: "POST", - headers: API_KEY ? { "X-API-Key": API_KEY } : {}, + headers: authHeader, body: formData, }); if (!resp.ok) { @@ -35,14 +39,14 @@ export async function uploadToBackend(file) { * @param {Array} records - scored RecordOut objects from the upload response * @param {string|null} batchId - batch_id from the upload response (for audit trail) */ -export async function exportFromBackend(records, batchId = null) { +export async function exportFromBackend(records, batchId = null, token = null) { + const authHeader = token + ? { "Authorization": `Bearer ${token}` } + : API_KEY ? { "X-API-Key": API_KEY } : {}; try { const resp = await fetch(`${BACKEND_URL}/api/export`, { method: "POST", - headers: { - "Content-Type": "application/json", - ...(API_KEY ? { "X-API-Key": API_KEY } : {}), - }, + headers: { "Content-Type": "application/json", ...authHeader }, body: JSON.stringify({ records, batch_id: batchId }), }); if (!resp.ok) return null; diff --git a/signal-ui/src/main.jsx b/signal-ui/src/main.jsx index 54b1c91..c10184c 100644 --- a/signal-ui/src/main.jsx +++ b/signal-ui/src/main.jsx @@ -1,10 +1,19 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { ClerkProvider } from "@clerk/react"; import App from "./App.jsx"; import "./index.css"; +const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +if (!PUBLISHABLE_KEY) { + throw new Error("Missing VITE_CLERK_PUBLISHABLE_KEY — add it to signal-ui/.env"); +} + createRoot(document.getElementById("root")).render( - + + + ); \ No newline at end of file