Add Phase 3: Clerk auth with org-scoped data isolation
Backend: JWT middleware validates Clerk tokens on every request, extracts org ID from claims, enforces org-scoped queries via Supabase RLS. Frontend: ClerkProvider wraps the app, auth gate blocks unauthenticated access, UserButton in header, token injected into every API call. Supabase production wired to trust Clerk JWTs via Third-Party Auth integration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
35a61e11d5
commit
ec2cd24bd7
11 changed files with 445 additions and 85 deletions
187
docs/phase3-setup-checklist.md
Normal file
187
docs/phase3-setup-checklist.md
Normal file
|
|
@ -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: `<Frontend API URL>/.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://<staging-project>.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
|
||||||
|
```
|
||||||
|
|
@ -5,9 +5,12 @@ import io
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError
|
||||||
from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
|
from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
@ -49,11 +52,54 @@ app.add_middleware(
|
||||||
# API key auth — enforced when SIGNAL_API_KEY env var is set.
|
# 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.
|
# In dev (no env var), all requests pass. In production, X-API-Key header is required.
|
||||||
_api_key = os.getenv("SIGNAL_API_KEY", "")
|
_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:
|
@lru_cache(maxsize=1)
|
||||||
if _api_key and x_api_key != _api_key:
|
def _get_jwks_client() -> PyJWKClient:
|
||||||
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
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 = {
|
DEVICE_DISPLAY = {
|
||||||
"dexcom_g7": "Dexcom G7",
|
"dexcom_g7": "Dexcom G7",
|
||||||
|
|
@ -186,7 +232,7 @@ def health_db():
|
||||||
@app.post("/api/upload", response_model=UploadResponse)
|
@app.post("/api/upload", response_model=UploadResponse)
|
||||||
async def upload_csv(
|
async def upload_csv(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
_auth: None = Depends(_require_api_key),
|
claims: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
if not (file.filename or "").endswith(".csv"):
|
if not (file.filename or "").endswith(".csv"):
|
||||||
raise HTTPException(status_code=400, detail="File must be a .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",
|
log_event(AuditAction.CSV_INGEST, file.filename or "unknown", "demo_user",
|
||||||
"success", "0.0.0.0", detail=f"{len(out)} records scored")
|
"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(
|
batch_id = persist_upload(
|
||||||
filename=file.filename or "unknown",
|
filename=file.filename or "unknown",
|
||||||
content_bytes=content,
|
content_bytes=content,
|
||||||
|
|
@ -224,6 +271,7 @@ async def upload_csv(
|
||||||
coverage_results=results,
|
coverage_results=results,
|
||||||
skipped_count=len(skipped_reasons),
|
skipped_count=len(skipped_reasons),
|
||||||
mapping_summary=mapping_summary,
|
mapping_summary=mapping_summary,
|
||||||
|
clerk_org_id=clerk_org_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return UploadResponse(
|
return UploadResponse(
|
||||||
|
|
@ -245,7 +293,7 @@ class ExportRequest(BaseModel):
|
||||||
@app.post("/api/export")
|
@app.post("/api/export")
|
||||||
async def export_work_queue(
|
async def export_work_queue(
|
||||||
body: ExportRequest,
|
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."""
|
"""Generate a downloadable work-queue CSV from a list of scored records."""
|
||||||
records = body.records
|
records = body.records
|
||||||
|
|
|
||||||
|
|
@ -21,21 +21,40 @@ def _sha256(value: str) -> str:
|
||||||
return hashlib.sha256(value.encode()).hexdigest()
|
return hashlib.sha256(value.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_org() -> str | None:
|
def _get_or_create_org(clerk_org_id: str | None = None) -> str | None:
|
||||||
global _demo_org_id
|
"""
|
||||||
if _demo_org_id:
|
Look up the Supabase org UUID.
|
||||||
return _demo_org_id
|
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()
|
client = get_client()
|
||||||
if not client:
|
if not client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
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()
|
result = client.table("organizations").select("id").eq("slug", DEMO_ORG_SLUG).execute()
|
||||||
if result.data:
|
if result.data:
|
||||||
_demo_org_id = result.data[0]["id"]
|
_demo_org_id = result.data[0]["id"]
|
||||||
return _demo_org_id
|
return _demo_org_id
|
||||||
|
|
||||||
result = client.table("organizations").insert({
|
result = client.table("organizations").insert({
|
||||||
"name": "Gaboro DME — Pilot",
|
"name": "Gaboro DME — Pilot",
|
||||||
"slug": DEMO_ORG_SLUG,
|
"slug": DEMO_ORG_SLUG,
|
||||||
|
|
@ -56,6 +75,7 @@ def persist_upload(
|
||||||
coverage_results: list,
|
coverage_results: list,
|
||||||
skipped_count: int,
|
skipped_count: int,
|
||||||
mapping_summary: dict,
|
mapping_summary: dict,
|
||||||
|
clerk_org_id: str | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Persist one upload batch and all related records to Supabase.
|
Persist one upload batch and all related records to Supabase.
|
||||||
|
|
@ -65,7 +85,7 @@ def persist_upload(
|
||||||
if not client:
|
if not client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
org_id = _get_or_create_org()
|
org_id = _get_or_create_org(clerk_org_id=clerk_org_id)
|
||||||
if not org_id:
|
if not org_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@ uvicorn[standard]>=0.29.0
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
supabase>=2.0.0
|
supabase>=2.0.0
|
||||||
|
PyJWT[cryptography]>=2.8.0
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,13 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clerk/react": "^6.7.2",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6"
|
||||||
},
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"ignoredBuiltDependencies": ["@clerk/shared"]
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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:
|
react:
|
||||||
specifier: ^19.2.6
|
specifier: ^19.2.6
|
||||||
version: 19.2.6
|
version: 19.2.6
|
||||||
|
|
@ -118,6 +121,25 @@ packages:
|
||||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
engines: {node: '>=6.9.0'}
|
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':
|
'@emnapi/core@1.10.0':
|
||||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||||
|
|
||||||
|
|
@ -403,6 +425,9 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.100.14':
|
||||||
|
resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.2':
|
'@tybys/wasm-util@0.10.2':
|
||||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||||
|
|
||||||
|
|
@ -492,6 +517,10 @@ packages:
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
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:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -610,6 +639,9 @@ packages:
|
||||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
glob-to-regexp@0.4.1:
|
||||||
|
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
|
||||||
|
|
||||||
globals@17.6.0:
|
globals@17.6.0:
|
||||||
resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
|
resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -646,6 +678,10 @@ packages:
|
||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
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:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
|
|
@ -850,6 +886,9 @@ packages:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
std-env@3.10.0:
|
||||||
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
tailwindcss@4.3.0:
|
tailwindcss@4.3.0:
|
||||||
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
||||||
|
|
||||||
|
|
@ -1047,6 +1086,24 @@ snapshots:
|
||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@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':
|
'@emnapi/core@1.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.2.1
|
'@emnapi/wasi-threads': 1.2.1
|
||||||
|
|
@ -1260,6 +1317,8 @@ snapshots:
|
||||||
tailwindcss: 4.3.0
|
tailwindcss: 4.3.0
|
||||||
vite: 8.0.14(jiti@2.7.0)
|
vite: 8.0.14(jiti@2.7.0)
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.100.14': {}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.2':
|
'@tybys/wasm-util@0.10.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
@ -1331,6 +1390,8 @@ snapshots:
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.361: {}
|
electron-to-chromium@1.5.361: {}
|
||||||
|
|
@ -1460,6 +1521,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
|
|
||||||
|
glob-to-regexp@0.4.1: {}
|
||||||
|
|
||||||
globals@17.6.0: {}
|
globals@17.6.0: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
@ -1484,6 +1547,8 @@ snapshots:
|
||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
|
js-cookie@3.0.7: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
jsesc@3.1.0: {}
|
jsesc@3.1.0: {}
|
||||||
|
|
@ -1653,6 +1718,8 @@ snapshots:
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
tailwindcss@4.3.0: {}
|
tailwindcss@4.3.0: {}
|
||||||
|
|
||||||
tapable@2.3.3: {}
|
tapable@2.3.3: {}
|
||||||
|
|
@ -1662,8 +1729,7 @@ snapshots:
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
2
signal-ui/pnpm-workspace.yaml
Normal file
2
signal-ui/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
allowBuilds:
|
||||||
|
'@clerk/shared': set this to true or false
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
import { SignIn, UserButton, useAuth } from "@clerk/react";
|
||||||
import { ThemeProvider } from "./ThemeProvider";
|
import { ThemeProvider } from "./ThemeProvider";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import StatCard from "./components/StatCard";
|
import StatCard from "./components/StatCard";
|
||||||
|
|
@ -12,7 +13,9 @@ import { uploadToBackend, apiRecordToLocal } from "./lib/api";
|
||||||
import { parseCSV, processBatch } from "./lib/coverage";
|
import { parseCSV, processBatch } from "./lib/coverage";
|
||||||
|
|
||||||
function AppInner() {
|
function AppInner() {
|
||||||
|
const { getToken } = useAuth();
|
||||||
const [records, setRecords] = useState([]);
|
const [records, setRecords] = useState([]);
|
||||||
|
const [batchId, setBatchId] = useState(null);
|
||||||
const [activeFilter, setActiveFilter] = useState("all");
|
const [activeFilter, setActiveFilter] = useState("all");
|
||||||
const [importLabel, setImportLabel] = useState("No data imported");
|
const [importLabel, setImportLabel] = useState("No data imported");
|
||||||
const csvImportRef = useRef(null);
|
const csvImportRef = useRef(null);
|
||||||
|
|
@ -23,38 +26,41 @@ function AppInner() {
|
||||||
const okCount = records.filter((r) => r.flag === "OK").length;
|
const okCount = records.filter((r) => r.flag === "OK").length;
|
||||||
const urgent = ooc + visitDue;
|
const urgent = ooc + visitDue;
|
||||||
|
|
||||||
const handleResults = useCallback((file) => {
|
const handleResults = useCallback(async (file) => {
|
||||||
const label = new Date().toLocaleDateString("en-US", {
|
const label = new Date().toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadToBackend(file).then((data) => {
|
const token = await getToken().catch(() => null);
|
||||||
if (data) {
|
const data = await uploadToBackend(file, token);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backend unreachable — process locally
|
if (data) {
|
||||||
const reader = new FileReader();
|
const results = data.records.map(apiRecordToLocal);
|
||||||
reader.onload = (e) => {
|
setRecords(results);
|
||||||
const rows = parseCSV(e.target.result);
|
setBatchId(data.batch_id || null);
|
||||||
const { results, skipped } = processBatch(rows);
|
setImportLabel(`${file.name} · ${label} · via Signal API`);
|
||||||
setRecords(results);
|
let msg = `Loaded ${data.total} patient${data.total !== 1 ? "s" : ""} from ${file.name}`;
|
||||||
setImportLabel(`${file.name} · ${label} · local processing`);
|
if (data.skipped) msg += ` · ${data.skipped} skipped`;
|
||||||
let msg = `Loaded ${results.length} patient${results.length !== 1 ? "s" : ""} from ${file.name}`;
|
showToast(msg);
|
||||||
if (skipped.length) msg += ` · ${skipped.length} skipped`;
|
return;
|
||||||
showToast(msg);
|
}
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
// 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 (
|
return (
|
||||||
<div className="flex w-full min-h-screen bg-[var(--bg-page)] text-[var(--text-primary)] transition-colors">
|
<div className="flex w-full min-h-screen bg-[var(--bg-page)] text-[var(--text-primary)] transition-colors">
|
||||||
|
|
@ -88,7 +94,8 @@ function AppInner() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-[10px]">
|
<div className="flex items-center gap-[10px]">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<CSVExport records={records} />
|
<CSVExport records={records} batchId={batchId} />
|
||||||
|
<UserButton />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -129,6 +136,20 @@ function AppInner() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { isSignedIn, isLoaded } = useAuth();
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return <div className="flex items-center justify-center min-h-screen bg-[#F0EAE1]" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSignedIn) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-[#F0EAE1]">
|
||||||
|
<SignIn routing="hash" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AppInner />
|
<AppInner />
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,48 @@
|
||||||
|
import { useAuth } from "@clerk/react";
|
||||||
|
import { exportFromBackend } from "../lib/api";
|
||||||
import { getFlagLabel, getFlagAction, getDeviceDisplay } from "../lib/coverage";
|
import { getFlagLabel, getFlagAction, getDeviceDisplay } from "../lib/coverage";
|
||||||
|
|
||||||
export default function CSVExport({ records }) {
|
function downloadBlob(blob, filename) {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
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 (!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 = [
|
const headers = [
|
||||||
"Patient ID",
|
"Patient ID", "Device", "Payer", "Status", "Priority Score",
|
||||||
"Device",
|
"Days Until Resupply End", "Recommended Action", "Resupply End Date", "Reason",
|
||||||
"Payer",
|
|
||||||
"Status",
|
|
||||||
"Priority Score",
|
|
||||||
"Days Until Resupply End",
|
|
||||||
"Recommended Action",
|
|
||||||
"Resupply End Date",
|
|
||||||
"Reason",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = records.map((r) => [
|
const rows = records.map((r) => [
|
||||||
r.patient_id,
|
r.patient_id, getDeviceDisplay(r.device_type), r.payer, getFlagLabel(r.flag),
|
||||||
getDeviceDisplay(r.device_type),
|
r.priority, r.daysUntilEnd, getFlagAction(r.flag), r.coverageEndDate || "", r.reason || "",
|
||||||
r.payer,
|
|
||||||
getFlagLabel(r.flag),
|
|
||||||
r.priority,
|
|
||||||
r.daysUntilEnd,
|
|
||||||
getFlagAction(r.flag),
|
|
||||||
r.coverageEndDate || "",
|
|
||||||
r.reason || "",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const csv = [headers, ...rows]
|
const csv = [headers, ...rows]
|
||||||
.map((row) =>
|
.map((row) => row.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(","))
|
||||||
row.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",")
|
|
||||||
)
|
|
||||||
.join("\r\n");
|
.join("\r\n");
|
||||||
|
downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8;" }), filename);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
* app can fall back to client-side coverage calculation.
|
* 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 || "";
|
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
|
* @param {File} file
|
||||||
* @returns {Promise<object|null>} API response or null on failure
|
* @returns {Promise<object|null>} API response or null on failure
|
||||||
*/
|
*/
|
||||||
export async function uploadToBackend(file) {
|
export async function uploadToBackend(file, token = null) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
const authHeader = token
|
||||||
|
? { "Authorization": `Bearer ${token}` }
|
||||||
|
: API_KEY ? { "X-API-Key": API_KEY } : {};
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${BACKEND_URL}/api/upload`, {
|
const resp = await fetch(`${BACKEND_URL}/api/upload`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: API_KEY ? { "X-API-Key": API_KEY } : {},
|
headers: authHeader,
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
|
@ -35,14 +39,14 @@ export async function uploadToBackend(file) {
|
||||||
* @param {Array} records - scored RecordOut objects from the upload response
|
* @param {Array} records - scored RecordOut objects from the upload response
|
||||||
* @param {string|null} batchId - batch_id from the upload response (for audit trail)
|
* @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 {
|
try {
|
||||||
const resp = await fetch(`${BACKEND_URL}/api/export`, {
|
const resp = await fetch(`${BACKEND_URL}/api/export`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", ...authHeader },
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(API_KEY ? { "X-API-Key": API_KEY } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ records, batch_id: batchId }),
|
body: JSON.stringify({ records, batch_id: batchId }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) return null;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { ClerkProvider } from "@clerk/react";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
import "./index.css";
|
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(
|
createRoot(document.getElementById("root")).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<ClerkProvider publishableKey={PUBLISHABLE_KEY}>
|
||||||
|
<App />
|
||||||
|
</ClerkProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
Loading…
Reference in a new issue