From d1fbfa50a4d59b509bad733569c8d279b7e1651e Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:15:24 -0700 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20deploy=20EHR=20demo=20to=20Azure?= =?UTF-8?q?=20via=20Aspire=20+=20azd=20=E2=86=92=20ACA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Dashboard Dockerfile (multi-stage Node build → nginx) and nginx.conf with SPA routing and /api reverse proxy to Gateway - Simplify GraphQL client to use relative /api/graphql (works with both Vite dev proxy and nginx prod proxy) - Update AppHost with IsPublishMode conditional: Vite dev server for local, containerized nginx for Azure deployment - Add Azure hosting NuGet packages for PostgreSQL and Redis - Generate azd infrastructure (Bicep + ACA templates) with .Env references for parameter resolution - Add azd-sync-secrets.sh to sync dotnet user-secrets → azd env vars Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/dashboard/Dockerfile | 34 +++ apps/dashboard/nginx.conf | 32 +++ apps/dashboard/src/api/graphqlClient.ts | 8 +- docs/designs/2026-03-15-deploy-ehr-demo.md | 200 ++++++++++++++++++ docs/plans/2026-03-15-deploy-ehr-demo.md | 194 +++++++++++++++++ orchestration/AuthScript.AppHost/.gitignore | 1 + orchestration/AuthScript.AppHost/AppHost.cs | 25 ++- .../AuthScript.AppHost.csproj | 2 + orchestration/AuthScript.AppHost/azure.yaml | 8 + .../infra/dashboard.tmpl.yaml | 36 ++++ .../infra/dashboard/Dockerfile | 34 +++ .../infra/gateway.tmpl.yaml | 100 +++++++++ .../infra/intelligence.tmpl.yaml | 63 ++++++ .../infra/intelligence/Dockerfile | 55 +++++ .../AuthScript.AppHost/infra/main.bicep | 78 +++++++ .../infra/main.parameters.json | 52 +++++ .../infra/postgres.tmpl.yaml | 52 +++++ .../AuthScript.AppHost/infra/redis.tmpl.yaml | 50 +++++ .../AuthScript.AppHost/infra/resources.bicep | 148 +++++++++++++ package.json | 2 +- scripts/azd-sync-secrets.sh | 44 ++++ 21 files changed, 1205 insertions(+), 13 deletions(-) create mode 100644 apps/dashboard/Dockerfile create mode 100644 apps/dashboard/nginx.conf create mode 100644 docs/designs/2026-03-15-deploy-ehr-demo.md create mode 100644 docs/plans/2026-03-15-deploy-ehr-demo.md create mode 100644 orchestration/AuthScript.AppHost/.gitignore create mode 100644 orchestration/AuthScript.AppHost/azure.yaml create mode 100644 orchestration/AuthScript.AppHost/infra/dashboard.tmpl.yaml create mode 100644 orchestration/AuthScript.AppHost/infra/dashboard/Dockerfile create mode 100644 orchestration/AuthScript.AppHost/infra/gateway.tmpl.yaml create mode 100644 orchestration/AuthScript.AppHost/infra/intelligence.tmpl.yaml create mode 100644 orchestration/AuthScript.AppHost/infra/intelligence/Dockerfile create mode 100644 orchestration/AuthScript.AppHost/infra/main.bicep create mode 100644 orchestration/AuthScript.AppHost/infra/main.parameters.json create mode 100644 orchestration/AuthScript.AppHost/infra/postgres.tmpl.yaml create mode 100644 orchestration/AuthScript.AppHost/infra/redis.tmpl.yaml create mode 100644 orchestration/AuthScript.AppHost/infra/resources.bicep create mode 100755 scripts/azd-sync-secrets.sh diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile new file mode 100644 index 0000000..783a2a6 --- /dev/null +++ b/apps/dashboard/Dockerfile @@ -0,0 +1,34 @@ +# =========================================================================== +# AuthScript Dashboard - Multi-stage Docker build +# Stage 1: Build React SPA with Vite +# Stage 2: Serve via nginx with API reverse proxy +# Build context: repository root +# =========================================================================== + +# Build stage +FROM docker.io/node:20-alpine AS build +WORKDIR /app + +# Copy all workspace files (node_modules excluded via .dockerignore) +COPY package.json package-lock.json ./ +COPY shared/ shared/ +COPY apps/dashboard/ apps/dashboard/ + +# Install dependencies and build +RUN npm ci +RUN npm run build --workspace=shared/types && npm run build --workspace=shared/validation +RUN npm run build --workspace=apps/dashboard + +# Serve stage +FROM docker.io/nginx:alpine + +# Copy built SPA assets +COPY --from=build /app/apps/dashboard/dist /usr/share/nginx/html + +# Copy nginx config template (envsubst resolves $GATEWAY_URL at startup) +COPY apps/dashboard/nginx.conf /etc/nginx/templates/default.conf.template + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD wget -q --spider http://localhost/health || exit 1 diff --git a/apps/dashboard/nginx.conf b/apps/dashboard/nginx.conf new file mode 100644 index 0000000..1fcd3a5 --- /dev/null +++ b/apps/dashboard/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback — serve index.html for client-side routes + location / { + try_files $uri $uri/ /index.html; + } + + # Reverse proxy /api to Gateway service (resolved at startup via envsubst) + location /api/ { + proxy_pass ${GATEWAY_URL}/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Cache static assets + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Health check + location /health { + return 200 'ok'; + add_header Content-Type text/plain; + } +} diff --git a/apps/dashboard/src/api/graphqlClient.ts b/apps/dashboard/src/api/graphqlClient.ts index 6034fee..7d9bc8c 100644 --- a/apps/dashboard/src/api/graphqlClient.ts +++ b/apps/dashboard/src/api/graphqlClient.ts @@ -1,15 +1,11 @@ /** * GraphQL client for AuthScript Gateway API - * Uses VITE_GATEWAY_URL (default http://localhost:5000) for direct calls. - * When using Vite dev proxy, /api is proxied to the Gateway. + * Uses relative /api/graphql path — proxied to Gateway by Vite (dev) or nginx (prod). */ import { GraphQLClient } from 'graphql-request'; -import { getApiConfig } from '../config/secrets'; -const GRAPHQL_ENDPOINT = import.meta.env.DEV - ? `${window.location.origin}/api/graphql` - : `${getApiConfig().gatewayUrl}/api/graphql`; +const GRAPHQL_ENDPOINT = '/api/graphql'; export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, { credentials: 'include', diff --git a/docs/designs/2026-03-15-deploy-ehr-demo.md b/docs/designs/2026-03-15-deploy-ehr-demo.md new file mode 100644 index 0000000..128fd06 --- /dev/null +++ b/docs/designs/2026-03-15-deploy-ehr-demo.md @@ -0,0 +1,200 @@ +# Deploy EHR Demo to Azure + +**Date:** 2026-03-15 +**Status:** Draft +**Approach:** Aspire + `azd` → Azure Container Apps + +## Problem + +The EHR demo runs locally via .NET Aspire. We need it publicly accessible for demo +purposes (low traffic, cost-sensitive, no HA requirements). + +## Approach + +Use the **Aspire ↔ Azure Developer CLI (`azd`) integration** — the designed deployment +path for Aspire apps. `azd init` reads AppHost.cs and generates Bicep automatically. +All three services deploy as Azure Container Apps with managed data stores. + +## Architecture + +``` +Internet + │ + ├─► Dashboard (ACA) ──► nginx serving built SPA + │ └─ /api proxy ──► Gateway (ACA) + │ + ├─► Gateway (ACA, .NET 10) + │ ├─► Azure DB for PostgreSQL (Flexible Server) + │ ├─► Azure Cache for Redis + │ └─► Intelligence (ACA, internal) + │ + └─► Intelligence (ACA, Python/FastAPI, internal) + └─► LLM Provider (Azure OpenAI / GitHub Models) +``` + +**Ingress:** +- Dashboard: external (public HTTPS endpoint) +- Gateway: external (needed for GraphQL from SPA) +- Intelligence: internal only (called by Gateway) + +## Changes Required + +### 1. Dashboard Dockerfile (new) + +Create `apps/dashboard/Dockerfile` — multi-stage build: +- **Stage 1 (build):** Node 20, install deps, `npm run build` +- **Stage 2 (serve):** nginx:alpine, copy built assets, proxy `/api` → Gateway + +The nginx config handles SPA routing (fallback to `index.html`) and reverse-proxies +`/api` requests to the Gateway service URL (injected at container start). + +```dockerfile +# Build stage +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +COPY apps/dashboard/package.json apps/dashboard/ +COPY shared/ shared/ +RUN npm ci --workspace=apps/dashboard --workspace=shared/types --workspace=shared/validation +COPY apps/dashboard/ apps/dashboard/ +ARG VITE_GATEWAY_URL=/api +ENV VITE_GATEWAY_URL=$VITE_GATEWAY_URL +RUN npm run build --workspace=shared/types && npm run build --workspace=shared/validation +RUN npm run build --workspace=apps/dashboard + +# Serve stage +FROM nginx:alpine +COPY --from=build /app/apps/dashboard/dist /usr/share/nginx/html +COPY apps/dashboard/nginx.conf /etc/nginx/templates/default.conf.template +EXPOSE 80 +``` + +### 2. Dashboard nginx.conf (new) + +Create `apps/dashboard/nginx.conf` with: +- SPA fallback routing (`try_files $uri $uri/ /index.html`) +- Reverse proxy `/api` → `${GATEWAY_URL}` (resolved at container startup via envsubst) +- Cache headers for static assets + +### 3. AppHost.cs Modifications + +Update the AppHost to support both local dev and Azure deployment: + +```csharp +// Change: Use AddDockerfile for dashboard when publishing +var dashboard = builder + .AddDockerfile("dashboard", "../../apps/dashboard") + .WithHttpEndpoint(port: 80, targetPort: 80, name: "dashboard-http") + .WithExternalHttpEndpoints() + .WaitFor(gateway) + .WithEnvironment("GATEWAY_URL", gateway.GetEndpoint("gateway-api")); +``` + +**Note:** This changes the dashboard from `AddViteApp` (dev server) to +`AddDockerfile` (containerized). For local dev, we can use a launch profile +or conditional logic to preserve the Vite dev server experience. The simplest +approach is a `#if` directive or checking `builder.ExecutionContext.IsPublishMode`. + +```csharp +if (builder.ExecutionContext.IsPublishMode) +{ + // Containerized for Azure deployment + builder.AddDockerfile("dashboard", "../../apps/dashboard") + .WithHttpEndpoint(port: 80, targetPort: 80) + .WithExternalHttpEndpoints() + .WaitFor(gateway) + .WithEnvironment("GATEWAY_URL", gateway.GetEndpoint("gateway-api")); +} +else +{ + // Vite dev server for local development + builder.AddViteApp("dashboard", "../../apps/dashboard") + .WaitFor(gateway) + .WithEnvironment("VITE_GATEWAY_URL", gateway.GetEndpoint("gateway-api")) + .WithEnvironment("VITE_INTELLIGENCE_URL", intelligence.GetEndpoint("intelligence-api")); +} +``` + +### 4. AppHost NuGet Packages (add) + +Add Azure hosting packages to `AuthScript.AppHost.csproj`: + +```xml + + +``` + +These enable `azd` to provision managed Azure data stores instead of containers. + +### 5. Deployment Workflow + +```bash +# One-time setup +cd orchestration/AuthScript.AppHost +azd init # Generates azure.yaml + infra/ + +# Configure secrets +azd env set ATHENA_CLIENT_ID +azd env set ATHENA_CLIENT_SECRET +azd env set ATHENA_PRACTICE_ID 195900 +azd env set GITHUB_TOKEN # or AZURE_OPENAI_API_KEY +azd env set LLM_PROVIDER github # or azure + +# Deploy +azd up # Provisions infra + deploys all services + +# Subsequent deployments +azd deploy # Redeploy code only (no infra changes) + +# Tear down when done +azd down # Deletes all Azure resources +``` + +### 6. Gateway CORS Configuration + +The Gateway needs to allow requests from the Dashboard's ACA URL. Add the +Dashboard URL to the CORS allowed origins: + +```csharp +// In Gateway Program.cs — add ACA dashboard URL to allowed origins +.WithEnvironment("AllowedOrigins", dashboard.GetEndpoint("dashboard-http")) +``` + +**Note:** Since the Dashboard nginx proxies `/api` to the Gateway, CORS may not +be needed at all — the browser sees same-origin requests. This depends on whether +the Dashboard makes direct calls to the Gateway URL or uses the nginx proxy. + +## Cost Estimate (Demo Traffic) + +| Resource | SKU | Est. Monthly Cost | +|----------|-----|-------------------| +| Azure Container Apps (3 services) | Consumption (scale-to-zero) | ~$0-5 | +| Azure DB for PostgreSQL | Burstable B1ms | ~$13 | +| Azure Cache for Redis | Basic C0 | ~$16 | +| Container Registry | Basic | ~$5 | +| **Total** | | **~$34-39/mo** | + +**Cost optimization options:** +- Use container-based PostgreSQL/Redis in ACA instead of managed services (~$5/mo total) +- Use `azd down` to tear down between demo sessions ($0 when down) + +## Secrets Management + +Aspire parameters map to Azure Key Vault or ACA secrets automatically via `azd`. +The existing `builder.AddParameter("name", secret: true)` declarations already +mark which values are sensitive — `azd` stores these as ACA secrets. + +## Out of Scope + +- Custom domain / DNS (can add later via ACA custom domain binding) +- CI/CD pipeline for auto-deploy (can add GitHub Actions `azd` workflow later) +- Authentication / access control on the demo +- High availability / multi-region + +## Implementation Tasks + +1. Create Dashboard Dockerfile + nginx.conf +2. Update AppHost.cs with publish-mode conditional + Azure packages +3. `azd init` + configure secrets +4. `azd up` and verify +5. Test public URL end-to-end diff --git a/docs/plans/2026-03-15-deploy-ehr-demo.md b/docs/plans/2026-03-15-deploy-ehr-demo.md new file mode 100644 index 0000000..7e3ff1b --- /dev/null +++ b/docs/plans/2026-03-15-deploy-ehr-demo.md @@ -0,0 +1,194 @@ +# Implementation Plan: Deploy EHR Demo to Azure + +**Design:** `docs/designs/2026-03-15-deploy-ehr-demo.md` +**Date:** 2026-03-15 + +## Summary + +Deploy the EHR demo to Azure using Aspire + `azd` → Azure Container Apps. This is +primarily infrastructure work (Dockerfiles, nginx config, Aspire modifications) +with one small code change to the GraphQL client. + +## Key Insight: Proxy Architecture + +The Dashboard currently uses Vite's dev proxy (`/api` → `localhost:5000`). In +production, nginx does the same. This means `graphqlClient.ts` can always use +relative `/api/graphql` — no per-environment URL construction needed. + +--- + +## Task 1: Simplify GraphQL Client URL + +**Phase:** RED → GREEN + +Unify the GraphQL endpoint to always use relative `/api/graphql`. Both dev (Vite +proxy) and prod (nginx proxy) handle the `/api` reverse proxy, so the +environment-conditional URL construction is unnecessary. + +### 1a. [RED] Update test expectations + +- **File:** `apps/dashboard/src/api/__tests__/graphqlClient.test.ts` (new) +- Write a test that verifies the GraphQL client targets `/api/graphql` +- Expected failure: test file doesn't exist yet + +### 1b. [GREEN] Simplify graphqlClient.ts + +- **File:** `apps/dashboard/src/api/graphqlClient.ts` +- Change: `const GRAPHQL_ENDPOINT = '/api/graphql';` +- Remove the `import.meta.env.DEV` conditional and `getApiConfig()` import +- The `getApiConfig` function and `SecretsManager` remain (other code may use them later) + +### 1c. Verify existing tests pass + +- Run `npx vitest run` from `apps/dashboard/` to ensure no regressions + +**Dependencies:** None +**Parallelizable:** Yes (independent of infrastructure tasks) + +--- + +## Task 2: Create Dashboard Dockerfile + +**Phase:** GREEN (infrastructure — validated by successful build) + +Create a multi-stage Dockerfile that builds the React SPA and serves it via nginx. + +### 2a. Create nginx.conf + +- **File:** `apps/dashboard/nginx.conf` (new) +- SPA fallback: `try_files $uri $uri/ /index.html` +- Reverse proxy: `location /api/ { proxy_pass ${GATEWAY_URL}; }` +- Use `/etc/nginx/templates/default.conf.template` pattern for `envsubst` + at container startup (nginx:alpine does this automatically) +- Static asset caching headers for `/assets/` + +### 2b. Create Dockerfile + +- **File:** `apps/dashboard/Dockerfile` (new) +- **Stage 1 (build):** `node:20-alpine` + - Copy root `package.json`, `package-lock.json`, workspace configs + - Copy `shared/` (types + validation) and `apps/dashboard/` + - `npm ci` for relevant workspaces + - Build shared packages, then dashboard (`npm run build`) + - No `VITE_GATEWAY_URL` needed (relative URLs) +- **Stage 2 (serve):** `nginx:alpine` + - Copy built assets from stage 1 to `/usr/share/nginx/html` + - Copy `nginx.conf` to `/etc/nginx/templates/default.conf.template` + - Expose port 80 +- Build context: repository root (to access shared packages) + +### 2c. Validate Docker build + +- Run `docker build -f apps/dashboard/Dockerfile -t authscript-dashboard .` +- Verify container starts and serves the SPA + +**Dependencies:** Task 1 (relative URLs must be in place) +**Parallelizable:** No (depends on Task 1) + +--- + +## Task 3: Update AppHost for Azure Deployment + +**Phase:** GREEN (infrastructure — validated by successful build/run) + +Modify the Aspire AppHost to support both local dev (Vite) and publish mode +(containerized dashboard). + +### 3a. Add Azure hosting NuGet packages + +- **File:** `orchestration/AuthScript.AppHost/AuthScript.AppHost.csproj` +- Add: `Aspire.Hosting.Azure.PostgreSQL` (13.1.0) +- Add: `Aspire.Hosting.Azure.Redis` (13.1.0) + +### 3b. Update AppHost.cs with publish-mode conditional + +- **File:** `orchestration/AuthScript.AppHost/AppHost.cs` +- Wrap dashboard registration in `IsPublishMode` check: + - **Publish mode:** `AddDockerfile("dashboard", ...)` with port 80, + external endpoint, `GATEWAY_URL` env var pointing to Gateway endpoint + - **Run mode (dev):** Keep existing `AddViteApp` configuration +- Gateway and Intelligence remain unchanged (already have Dockerfiles) + +### 3c. Verify local dev still works + +- Run `dotnet build` on the AppHost to verify compilation +- Optionally: `npm run dev` to verify local dev experience unchanged + +**Dependencies:** Task 2 (Dashboard Dockerfile must exist for publish mode) +**Parallelizable:** No (depends on Task 2) + +--- + +## Task 4: Add build:containers script for Dashboard + +**Phase:** GREEN + +### 4a. Update root package.json + +- **File:** `package.json` (root) +- Add Dashboard to `build:containers` script: + ``` + docker build -f apps/dashboard/Dockerfile -t authscript-dashboard . + ``` + +**Dependencies:** Task 2 +**Parallelizable:** Yes (independent of Task 3) + +--- + +## Task 5: Azure Deployment (Interactive) + +**Phase:** Deploy + verify (not automatable in plan) + +This task is performed interactively with the user. It requires Azure credentials, +subscription selection, and secret configuration. + +### 5a. Initialize azd + +- Run `azd init` from `orchestration/AuthScript.AppHost/` +- Select Azure Container Apps as the target +- Review generated `azure.yaml` and `infra/` Bicep files + +### 5b. Configure secrets + +- `azd env set` for: Athena credentials, LLM provider key, practice ID + +### 5c. Deploy + +- `azd up` to provision infrastructure and deploy all services +- Verify all three ACA services are running +- Test Dashboard public URL → EHR demo flow end-to-end + +### 5d. Document deployment + +- Add deployment instructions to project README or a new `docs/DEPLOYMENT.md` +- Include: `azd up`, `azd deploy`, `azd down` workflows + +**Dependencies:** Tasks 1-3 +**Parallelizable:** No (requires all code changes complete) + +--- + +## Task Dependency Graph + +``` +Task 1 (GraphQL client) ──► Task 2 (Dockerfile) ──► Task 3 (AppHost) + │ │ + ▼ ▼ + Task 4 (scripts) Task 5 (deploy) +``` + +## Parallelization + +- **Tasks 1 + 4:** Cannot parallelize (Task 4 depends on Task 2) +- **Tasks 3 + 4:** Can run in parallel after Task 2 completes +- **Task 5:** Must be last, interactive with user + +## Delegation Strategy + +Tasks 1-4 are small and tightly coupled — best handled as a single sequential +implementation rather than delegated to parallel agents. Task 5 is interactive +and cannot be delegated. + +**Recommendation:** Implement Tasks 1-4 in a single branch, then do Task 5 +interactively with the user. diff --git a/orchestration/AuthScript.AppHost/.gitignore b/orchestration/AuthScript.AppHost/.gitignore new file mode 100644 index 0000000..8e84380 --- /dev/null +++ b/orchestration/AuthScript.AppHost/.gitignore @@ -0,0 +1 @@ +.azure diff --git a/orchestration/AuthScript.AppHost/AppHost.cs b/orchestration/AuthScript.AppHost/AppHost.cs index f587774..52f29c0 100644 --- a/orchestration/AuthScript.AppHost/AppHost.cs +++ b/orchestration/AuthScript.AppHost/AppHost.cs @@ -77,7 +77,6 @@ .WaitFor(redis) .WaitFor(intelligence) .WithHttpEndpoint(port: 5000, name: "gateway-api") - .WithExternalHttpEndpoints() // Athena configuration .WithEnvironment("Athena__ClientId", athenaClientId) .WithEnvironment("Athena__ClientSecret", athenaClientSecret) @@ -90,11 +89,25 @@ // --------------------------------------------------------------------------- // Dashboard (Vite + React) +// Publish mode: containerized with nginx (for Azure deployment) +// Run mode: Vite dev server (for local development) // --------------------------------------------------------------------------- -var dashboard = builder - .AddViteApp("dashboard", "../../apps/dashboard") - .WaitFor(gateway) - .WithEnvironment("VITE_GATEWAY_URL", gateway.GetEndpoint("gateway-api")) - .WithEnvironment("VITE_INTELLIGENCE_URL", intelligence.GetEndpoint("intelligence-api")); +if (builder.ExecutionContext.IsPublishMode) +{ + builder + .AddDockerfile("dashboard", "../..", "apps/dashboard/Dockerfile") + .WithHttpEndpoint(port: 80, targetPort: 80, name: "dashboard-http") + .WithExternalHttpEndpoints() + .WaitFor(gateway) + .WithEnvironment("GATEWAY_URL", gateway.GetEndpoint("gateway-api")); +} +else +{ + builder + .AddViteApp("dashboard", "../../apps/dashboard") + .WaitFor(gateway) + .WithEnvironment("VITE_GATEWAY_URL", gateway.GetEndpoint("gateway-api")) + .WithEnvironment("VITE_INTELLIGENCE_URL", intelligence.GetEndpoint("intelligence-api")); +} builder.Build().Run(); diff --git a/orchestration/AuthScript.AppHost/AuthScript.AppHost.csproj b/orchestration/AuthScript.AppHost/AuthScript.AppHost.csproj index 14bb753..4494f43 100644 --- a/orchestration/AuthScript.AppHost/AuthScript.AppHost.csproj +++ b/orchestration/AuthScript.AppHost/AuthScript.AppHost.csproj @@ -10,6 +10,8 @@ + + diff --git a/orchestration/AuthScript.AppHost/azure.yaml b/orchestration/AuthScript.AppHost/azure.yaml new file mode 100644 index 0000000..1bd643c --- /dev/null +++ b/orchestration/AuthScript.AppHost/azure.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: authscript-apphost +services: + app: + language: dotnet + project: ./AuthScript.AppHost.csproj + host: containerapp diff --git a/orchestration/AuthScript.AppHost/infra/dashboard.tmpl.yaml b/orchestration/AuthScript.AppHost/infra/dashboard.tmpl.yaml new file mode 100644 index 0000000..b6896a7 --- /dev/null +++ b/orchestration/AuthScript.AppHost/infra/dashboard.tmpl.yaml @@ -0,0 +1,36 @@ +api-version: 2024-02-02-preview +location: {{ .Env.AZURE_LOCATION }} +identity: + type: UserAssigned + userAssignedIdentities: + ? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}" + : {} +properties: + environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }} + configuration: + activeRevisionsMode: single + runtime: + dotnet: + autoConfigureDataProtection: true + ingress: + external: true + targetPort: 80 + transport: http + allowInsecure: false + registries: + - server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }} + identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }} + template: + containers: + - image: {{ .Image }} + name: dashboard + env: + - name: AZURE_CLIENT_ID + value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }} + - name: GATEWAY_URL + value: http://gateway:5000 + scale: + minReplicas: 1 +tags: + azd-service-name: dashboard + aspire-resource-name: dashboard diff --git a/orchestration/AuthScript.AppHost/infra/dashboard/Dockerfile b/orchestration/AuthScript.AppHost/infra/dashboard/Dockerfile new file mode 100644 index 0000000..783a2a6 --- /dev/null +++ b/orchestration/AuthScript.AppHost/infra/dashboard/Dockerfile @@ -0,0 +1,34 @@ +# =========================================================================== +# AuthScript Dashboard - Multi-stage Docker build +# Stage 1: Build React SPA with Vite +# Stage 2: Serve via nginx with API reverse proxy +# Build context: repository root +# =========================================================================== + +# Build stage +FROM docker.io/node:20-alpine AS build +WORKDIR /app + +# Copy all workspace files (node_modules excluded via .dockerignore) +COPY package.json package-lock.json ./ +COPY shared/ shared/ +COPY apps/dashboard/ apps/dashboard/ + +# Install dependencies and build +RUN npm ci +RUN npm run build --workspace=shared/types && npm run build --workspace=shared/validation +RUN npm run build --workspace=apps/dashboard + +# Serve stage +FROM docker.io/nginx:alpine + +# Copy built SPA assets +COPY --from=build /app/apps/dashboard/dist /usr/share/nginx/html + +# Copy nginx config template (envsubst resolves $GATEWAY_URL at startup) +COPY apps/dashboard/nginx.conf /etc/nginx/templates/default.conf.template + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD wget -q --spider http://localhost/health || exit 1 diff --git a/orchestration/AuthScript.AppHost/infra/gateway.tmpl.yaml b/orchestration/AuthScript.AppHost/infra/gateway.tmpl.yaml new file mode 100644 index 0000000..dee21aa --- /dev/null +++ b/orchestration/AuthScript.AppHost/infra/gateway.tmpl.yaml @@ -0,0 +1,100 @@ +api-version: 2024-02-02-preview +location: {{ .Env.AZURE_LOCATION }} +identity: + type: UserAssigned + userAssignedIdentities: + ? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}" + : {} +properties: + environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }} + configuration: + activeRevisionsMode: single + runtime: + dotnet: + autoConfigureDataProtection: true + ingress: + additionalPortMappings: + - targetPort: 8001 + external: false + external: false + targetPort: {{ targetPortOrDefault 8080 }} + transport: http + allowInsecure: true + registries: + - server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }} + identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }} + secrets: + - name: authscript-password + value: '{{ .Env.AZURE_POSTGRES_PASSWORD }}' + - name: athena--clientid + value: '{{ .Env.AZURE_ATHENA_CLIENT_ID }}' + - name: athena--clientsecret + value: '{{ .Env.AZURE_ATHENA_CLIENT_SECRET }}' + - name: connectionstrings--authscript + value: Host=postgres;Port=5432;Username=postgres;Password={{ .Env.AZURE_POSTGRES_PASSWORD }};Database=authscript + - name: connectionstrings--redis + value: redis:6379,password={{ .Env.AZURE_REDIS_PASSWORD }} + - name: redis-password + value: '{{ .Env.AZURE_REDIS_PASSWORD }}' + template: + containers: + - image: {{ .Image }} + name: gateway + env: + - name: AZURE_CLIENT_ID + value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }} + - name: ASPNETCORE_FORWARDEDHEADERS_ENABLED + value: "true" + - name: AUTHSCRIPT_DATABASENAME + value: authscript + - name: AUTHSCRIPT_HOST + value: postgres + - name: AUTHSCRIPT_JDBCCONNECTIONSTRING + value: jdbc:postgresql://postgres:5432/authscript + - name: AUTHSCRIPT_PORT + value: "5432" + - name: AUTHSCRIPT_URI + value: postgresql://postgres:{{ uriEncode (.Env.AZURE_POSTGRES_PASSWORD )}}@postgres:5432/authscript + - name: AUTHSCRIPT_USERNAME + value: postgres + - name: Athena__FhirBaseUrl + value: https://api.preview.platform.athenahealth.com/fhir/r4/ + - name: Athena__PracticeId + value: '{{ .Env.AZURE_ATHENA_PRACTICE_ID }}' + - name: Athena__TokenEndpoint + value: https://api.preview.platform.athenahealth.com/oauth2/v1/token + - name: Demo__EnableCaching + value: "true" + - name: HTTP_PORTS + value: '{{ targetPortOrDefault 0 }};8001' + - name: Intelligence__BaseUrl + value: http://intelligence.internal.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }} + - name: OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES + value: "true" + - name: OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES + value: "true" + - name: OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY + value: in_memory + - name: REDIS_HOST + value: redis + - name: REDIS_PORT + value: "6379" + - name: REDIS_URI + value: redis://:{{ uriEncode (.Env.AZURE_REDIS_PASSWORD )}}@redis:6379 + - name: AUTHSCRIPT_PASSWORD + secretRef: authscript-password + - name: Athena__ClientId + secretRef: athena--clientid + - name: Athena__ClientSecret + secretRef: athena--clientsecret + - name: ConnectionStrings__authscript + secretRef: connectionstrings--authscript + - name: ConnectionStrings__redis + secretRef: connectionstrings--redis + - name: REDIS_PASSWORD + secretRef: redis-password + scale: + minReplicas: 1 +tags: + azd-service-name: gateway + aspire-resource-name: gateway diff --git a/orchestration/AuthScript.AppHost/infra/intelligence.tmpl.yaml b/orchestration/AuthScript.AppHost/infra/intelligence.tmpl.yaml new file mode 100644 index 0000000..208fa47 --- /dev/null +++ b/orchestration/AuthScript.AppHost/infra/intelligence.tmpl.yaml @@ -0,0 +1,63 @@ +api-version: 2024-02-02-preview +location: {{ .Env.AZURE_LOCATION }} +identity: + type: UserAssigned + userAssignedIdentities: + ? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}" + : {} +properties: + environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }} + configuration: + activeRevisionsMode: single + runtime: + dotnet: + autoConfigureDataProtection: true + ingress: + external: false + targetPort: 8000 + transport: http + allowInsecure: true + registries: + - server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }} + identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }} + secrets: + - name: azure-openai-api-key + value: '{{ .Env.AZURE_AZURE_OPENAI_KEY }}' + - name: database-url + value: Host=postgres;Port=5432;Username=postgres;Password={{ .Env.AZURE_POSTGRES_PASSWORD }};Database=authscript + - name: github-token + value: '{{ .Env.AZURE_GITHUB_TOKEN }}' + - name: google-api-key + value: '{{ .Env.AZURE_GOOGLE_API_KEY }}' + - name: openai-api-key + value: '{{ .Env.AZURE_OPENAI_API_KEY }}' + - name: openai-org-id + value: '{{ .Env.AZURE_OPENAI_ORG_ID }}' + template: + containers: + - image: {{ .Image }} + name: intelligence + env: + - name: AZURE_CLIENT_ID + value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }} + - name: AZURE_OPENAI_ENDPOINT + value: '{{ .Env.AZURE_AZURE_OPENAI_ENDPOINT }}' + - name: LLM_PROVIDER + value: '{{ .Env.AZURE_LLM_PROVIDER }}' + - name: AZURE_OPENAI_API_KEY + secretRef: azure-openai-api-key + - name: DATABASE_URL + secretRef: database-url + - name: GITHUB_TOKEN + secretRef: github-token + - name: GOOGLE_API_KEY + secretRef: google-api-key + - name: OPENAI_API_KEY + secretRef: openai-api-key + - name: OPENAI_ORG_ID + secretRef: openai-org-id + scale: + minReplicas: 1 +tags: + azd-service-name: intelligence + aspire-resource-name: intelligence diff --git a/orchestration/AuthScript.AppHost/infra/intelligence/Dockerfile b/orchestration/AuthScript.AppHost/infra/intelligence/Dockerfile new file mode 100644 index 0000000..213c93e --- /dev/null +++ b/orchestration/AuthScript.AppHost/infra/intelligence/Dockerfile @@ -0,0 +1,55 @@ +# =========================================================================== +# AuthScript Intelligence Service - Multi-stage Docker Build +# Python 3.11 + FastAPI with uv for fast dependency resolution +# =========================================================================== + +# --------------------------------------------------------------------------- +# Stage 1: Build dependencies with uv +# --------------------------------------------------------------------------- +FROM ghcr.io/astral-sh/uv:0.5-python3.11-bookworm-slim AS builder + +WORKDIR /app + +# Copy dependency files first for layer caching +COPY pyproject.toml ./ + +# Install dependencies into .venv +RUN --mount=type=cache,target=/root/.cache/uv \ + uv venv .venv && \ + uv pip install --python .venv/bin/python -r pyproject.toml + +# Copy application source +COPY src/ ./src/ + +# --------------------------------------------------------------------------- +# Stage 2: Runtime image +# --------------------------------------------------------------------------- +FROM python:3.11-slim-bookworm AS runtime + +# Create non-root user for security +RUN groupadd --gid 1000 appgroup && \ + useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser + +WORKDIR /app + +# Copy virtual environment from builder +COPY --from=builder /app/.venv ./.venv +COPY --from=builder /app/src ./src + +# Set environment variables +ENV PATH="/app/.venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +# Switch to non-root user +USER appuser + +# Expose FastAPI port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +# Run uvicorn +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/orchestration/AuthScript.AppHost/infra/main.bicep b/orchestration/AuthScript.AppHost/infra/main.bicep new file mode 100644 index 0000000..2fad8c4 --- /dev/null +++ b/orchestration/AuthScript.AppHost/infra/main.bicep @@ -0,0 +1,78 @@ +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the environment that can be used as part of naming resource convention, the name of the resource group for your application will use this name, prefixed with rg-') +param environmentName string + +@minLength(1) +@description('The location used for all deployed resources') +param location string + +@description('Id of the user or app to assign application roles') +param principalId string = '' + +@secure() +param athena_client_id string +@secure() +param athena_client_secret string +param athena_practice_id string +param azure_openai_endpoint string +@secure() +param azure_openai_key string +@secure() +param github_token string +@secure() +param google_api_key string +param llm_provider string +@secure() +param openai_api_key string +@secure() +param openai_org_id string +@metadata({azd: { + type: 'generate' + config: {length:22} + } +}) +@secure() +param postgres_password string +@metadata({azd: { + type: 'generate' + config: {length:22,noSpecial:true} + } +}) +@secure() +param redis_password string + +var tags = { + 'azd-env-name': environmentName +} + +resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = { + name: 'rg-${environmentName}' + location: location + tags: tags +} +module resources 'resources.bicep' = { + scope: rg + name: 'resources' + params: { + location: location + tags: tags + principalId: principalId + } +} + + +output MANAGED_IDENTITY_CLIENT_ID string = resources.outputs.MANAGED_IDENTITY_CLIENT_ID +output MANAGED_IDENTITY_NAME string = resources.outputs.MANAGED_IDENTITY_NAME +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = resources.outputs.AZURE_LOG_ANALYTICS_WORKSPACE_NAME +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = resources.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID +output AZURE_CONTAINER_REGISTRY_NAME string = resources.outputs.AZURE_CONTAINER_REGISTRY_NAME +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_NAME +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN +output SERVICE_POSTGRES_VOLUME_AUTHSCRIPTPOSTGRESDATA_NAME string = resources.outputs.SERVICE_POSTGRES_VOLUME_AUTHSCRIPTPOSTGRESDATA_NAME +output SERVICE_REDIS_VOLUME_AUTHSCRIPTREDISDATA_NAME string = resources.outputs.SERVICE_REDIS_VOLUME_AUTHSCRIPTREDISDATA_NAME +output AZURE_VOLUMES_STORAGE_ACCOUNT string = resources.outputs.AZURE_VOLUMES_STORAGE_ACCOUNT diff --git a/orchestration/AuthScript.AppHost/infra/main.parameters.json b/orchestration/AuthScript.AppHost/infra/main.parameters.json new file mode 100644 index 0000000..7a80de5 --- /dev/null +++ b/orchestration/AuthScript.AppHost/infra/main.parameters.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "athena_client_id": { + "value": "${AZURE_ATHENA_CLIENT_ID}" + }, + "athena_client_secret": { + "value": "${AZURE_ATHENA_CLIENT_SECRET}" + }, + "athena_practice_id": { + "value": "${AZURE_ATHENA_PRACTICE_ID}" + }, + "azure_openai_endpoint": { + "value": "${AZURE_AZURE_OPENAI_ENDPOINT}" + }, + "azure_openai_key": { + "value": "${AZURE_AZURE_OPENAI_KEY}" + }, + "github_token": { + "value": "${AZURE_GITHUB_TOKEN}" + }, + "google_api_key": { + "value": "${AZURE_GOOGLE_API_KEY}" + }, + "llm_provider": { + "value": "${AZURE_LLM_PROVIDER}" + }, + "openai_api_key": { + "value": "${AZURE_OPENAI_API_KEY}" + }, + "openai_org_id": { + "value": "${AZURE_OPENAI_ORG_ID}" + }, + "postgres_password": { + "value": "${AZURE_POSTGRES_PASSWORD}" + }, + "redis_password": { + "value": "${AZURE_REDIS_PASSWORD}" + }, + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + } + } + } + \ No newline at end of file diff --git a/orchestration/AuthScript.AppHost/infra/postgres.tmpl.yaml b/orchestration/AuthScript.AppHost/infra/postgres.tmpl.yaml new file mode 100644 index 0000000..df9b3ff --- /dev/null +++ b/orchestration/AuthScript.AppHost/infra/postgres.tmpl.yaml @@ -0,0 +1,52 @@ +api-version: 2024-02-02-preview +location: {{ .Env.AZURE_LOCATION }} +identity: + type: UserAssigned + userAssignedIdentities: + ? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}" + : {} +properties: + environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }} + configuration: + activeRevisionsMode: single + runtime: + dotnet: + autoConfigureDataProtection: true + ingress: + external: false + targetPort: 5432 + transport: tcp + allowInsecure: false + registries: + - server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }} + identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }} + secrets: + - name: postgres-password + value: '{{ .Env.AZURE_POSTGRES_PASSWORD }}' + template: + volumes: + - name: postgres-authscriptpostgresdata + storageType: AzureFile + storageName: {{ .Env.SERVICE_POSTGRES_VOLUME_AUTHSCRIPTPOSTGRESDATA_NAME }} + containers: + - image: {{ .Image }} + name: postgres + env: + - name: AZURE_CLIENT_ID + value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }} + - name: POSTGRES_HOST_AUTH_METHOD + value: scram-sha-256 + - name: POSTGRES_INITDB_ARGS + value: --auth-host=scram-sha-256 --auth-local=scram-sha-256 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_PASSWORD + secretRef: postgres-password + volumeMounts: + - volumeName: postgres-authscriptpostgresdata + mountPath: /var/lib/postgresql/data + scale: + minReplicas: 1 +tags: + azd-service-name: postgres + aspire-resource-name: postgres diff --git a/orchestration/AuthScript.AppHost/infra/redis.tmpl.yaml b/orchestration/AuthScript.AppHost/infra/redis.tmpl.yaml new file mode 100644 index 0000000..43cf73f --- /dev/null +++ b/orchestration/AuthScript.AppHost/infra/redis.tmpl.yaml @@ -0,0 +1,50 @@ +api-version: 2024-02-02-preview +location: {{ .Env.AZURE_LOCATION }} +identity: + type: UserAssigned + userAssignedIdentities: + ? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}" + : {} +properties: + environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }} + configuration: + activeRevisionsMode: single + runtime: + dotnet: + autoConfigureDataProtection: true + ingress: + external: false + targetPort: 6379 + transport: tcp + allowInsecure: false + registries: + - server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }} + identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }} + secrets: + - name: redis-password + value: '{{ .Env.AZURE_REDIS_PASSWORD }}' + template: + volumes: + - name: redis-authscriptredisdata + storageType: AzureFile + storageName: {{ .Env.SERVICE_REDIS_VOLUME_AUTHSCRIPTREDISDATA_NAME }} + containers: + - image: {{ .Image }} + name: redis + args: + - -c + - redis-server --requirepass $REDIS_PASSWORD --save 60 1 + command: [/bin/sh] + env: + - name: AZURE_CLIENT_ID + value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }} + - name: REDIS_PASSWORD + secretRef: redis-password + volumeMounts: + - volumeName: redis-authscriptredisdata + mountPath: /data + scale: + minReplicas: 1 +tags: + azd-service-name: redis + aspire-resource-name: redis diff --git a/orchestration/AuthScript.AppHost/infra/resources.bicep b/orchestration/AuthScript.AppHost/infra/resources.bicep new file mode 100644 index 0000000..0ac344f --- /dev/null +++ b/orchestration/AuthScript.AppHost/infra/resources.bicep @@ -0,0 +1,148 @@ +@description('The location used for all deployed resources') +param location string = resourceGroup().location +@description('Id of the user or app to assign application roles') +param principalId string = '' + + +@description('Tags that will be applied to all resources') +param tags object = {} + +var resourceToken = uniqueString(resourceGroup().id) + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: 'mi-${resourceToken}' + location: location + tags: tags +} + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: replace('acr-${resourceToken}', '-', '') + location: location + sku: { + name: 'Basic' + } + tags: tags +} + +resource caeMiRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(containerRegistry.id, managedIdentity.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + scope: containerRegistry + properties: { + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } +} + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { + name: 'law-${resourceToken}' + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource storageVolume 'Microsoft.Storage/storageAccounts@2022-05-01' = { + name: 'vol${resourceToken}' + location: location + kind: 'StorageV2' + sku: { + name: 'Standard_LRS' + } + properties: { + largeFileSharesState: 'Enabled' + } +} + +resource storageVolumeFileService 'Microsoft.Storage/storageAccounts/fileServices@2022-05-01' = { + parent: storageVolume + name: 'default' +} + +resource postgresAuthscriptPostgresDataFileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = { + parent: storageVolumeFileService + name: take('${toLower('postgres')}-${toLower('authscriptpostgresdata')}', 60) + properties: { + shareQuota: 1024 + enabledProtocols: 'SMB' + } +} +resource redisAuthscriptRedisDataFileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = { + parent: storageVolumeFileService + name: take('${toLower('redis')}-${toLower('authscriptredisdata')}', 60) + properties: { + shareQuota: 1024 + enabledProtocols: 'SMB' + } +} + +resource containerAppEnvironment 'Microsoft.App/managedEnvironments@2024-02-02-preview' = { + name: 'cae-${resourceToken}' + location: location + properties: { + workloadProfiles: [{ + workloadProfileType: 'Consumption' + name: 'consumption' + }] + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspace.properties.customerId + sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey + } + } + } + tags: tags + + resource aspireDashboard 'dotNetComponents' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + } + +} + +resource postgresAuthscriptPostgresDataStore 'Microsoft.App/managedEnvironments/storages@2023-05-01' = { + parent: containerAppEnvironment + name: take('${toLower('postgres')}-${toLower('authscriptpostgresdata')}', 32) + properties: { + azureFile: { + shareName: postgresAuthscriptPostgresDataFileShare.name + accountName: storageVolume.name + accountKey: storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + } + } +} + +resource redisAuthscriptRedisDataStore 'Microsoft.App/managedEnvironments/storages@2023-05-01' = { + parent: containerAppEnvironment + name: take('${toLower('redis')}-${toLower('authscriptredisdata')}', 32) + properties: { + azureFile: { + shareName: redisAuthscriptRedisDataFileShare.name + accountName: storageVolume.name + accountKey: storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + } + } +} + +output MANAGED_IDENTITY_CLIENT_ID string = managedIdentity.properties.clientId +output MANAGED_IDENTITY_NAME string = managedIdentity.name +output MANAGED_IDENTITY_PRINCIPAL_ID string = managedIdentity.properties.principalId +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = logAnalyticsWorkspace.name +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = logAnalyticsWorkspace.id +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.properties.loginServer +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = managedIdentity.id +output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.name +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = containerAppEnvironment.name +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = containerAppEnvironment.id +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = containerAppEnvironment.properties.defaultDomain +output SERVICE_POSTGRES_VOLUME_AUTHSCRIPTPOSTGRESDATA_NAME string = postgresAuthscriptPostgresDataStore.name +output SERVICE_REDIS_VOLUME_AUTHSCRIPTREDISDATA_NAME string = redisAuthscriptRedisDataStore.name +output AZURE_VOLUMES_STORAGE_ACCOUNT string = storageVolume.name diff --git a/package.json b/package.json index e89ed93..c2d8ef0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build": "npm run build --workspaces --if-present", "build:dashboard": "npm run build -w apps/dashboard", "build:shared": "npm run build -w shared/types && npm run build -w shared/validation", - "build:containers": "docker build -t authscript-intelligence ./apps/intelligence && docker build -t authscript-gateway ./apps/gateway/Gateway.API", + "build:containers": "docker build -t authscript-intelligence ./apps/intelligence && docker build -t authscript-gateway ./apps/gateway/Gateway.API && docker build -f apps/dashboard/Dockerfile -t authscript-dashboard .", "test": "npm run test --workspaces --if-present", "test:dashboard": "npm run test -w apps/dashboard", "test:intelligence": "cd apps/intelligence && uv run pytest", diff --git a/scripts/azd-sync-secrets.sh b/scripts/azd-sync-secrets.sh new file mode 100755 index 0000000..26f236e --- /dev/null +++ b/scripts/azd-sync-secrets.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# =========================================================================== +# Sync dotnet user-secrets → azd environment variables +# azd expects AZURE_ prefix for Aspire infrastructure parameters +# Run from repo root. Requires: dotnet, azd +# =========================================================================== +set -euo pipefail + +APPHOST_DIR="$(cd "$(dirname "$0")/../orchestration/AuthScript.AppHost" && pwd)" + +# Map: dotnet user-secret key → azd env var (AZURE_ prefixed) +declare -A SECRET_MAP=( + ["Parameters:athena-client-id"]="AZURE_ATHENA_CLIENT_ID" + ["Parameters:athena-client-secret"]="AZURE_ATHENA_CLIENT_SECRET" + ["Parameters:athena-practice-id"]="AZURE_ATHENA_PRACTICE_ID" + ["Parameters:github-token"]="AZURE_GITHUB_TOKEN" + ["Parameters:llm-provider"]="AZURE_LLM_PROVIDER" + ["Parameters:azure-openai-key"]="AZURE_AZURE_OPENAI_KEY" + ["Parameters:azure-openai-endpoint"]="AZURE_AZURE_OPENAI_ENDPOINT" + ["Parameters:google-api-key"]="AZURE_GOOGLE_API_KEY" + ["Parameters:openai-api-key"]="AZURE_OPENAI_API_KEY" + ["Parameters:openai-org-id"]="AZURE_OPENAI_ORG_ID" + ["Parameters:postgres-password"]="AZURE_POSTGRES_PASSWORD" + ["Parameters:redis-password"]="AZURE_REDIS_PASSWORD" +) + +echo "Syncing dotnet user-secrets → azd env (AZURE_ prefixed)..." + +while IFS=' = ' read -r key value; do + [[ "$key" != Parameters:* ]] && continue + + azd_var="${SECRET_MAP[$key]:-}" + [[ -z "$azd_var" ]] && continue + + if [[ "$value" == "not-configured" ]]; then + echo " skip: $azd_var (not-configured)" + continue + fi + + echo " set: $azd_var" + (cd "$APPHOST_DIR" && azd env set "$azd_var" "$value" 2>/dev/null) +done < <(dotnet user-secrets list --project "$APPHOST_DIR") + +echo "Done. Run 'cd $APPHOST_DIR && azd up' to deploy." From 05f0b3b8555f065f98847aceb58ac465727f9f7f Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:17:22 -0700 Subject: [PATCH 02/21] fix: use absolute URL for GraphQL client (graphql-request requires it) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/dashboard/src/api/graphqlClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/api/graphqlClient.ts b/apps/dashboard/src/api/graphqlClient.ts index 7d9bc8c..a37df72 100644 --- a/apps/dashboard/src/api/graphqlClient.ts +++ b/apps/dashboard/src/api/graphqlClient.ts @@ -5,7 +5,7 @@ import { GraphQLClient } from 'graphql-request'; -const GRAPHQL_ENDPOINT = '/api/graphql'; +const GRAPHQL_ENDPOINT = `${window.location.origin}/api/graphql`; export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, { credentials: 'include', From a11c85a7b7ed9b8d1140baadf3aef6706e87c672 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:22:00 -0700 Subject: [PATCH 03/21] =?UTF-8?q?feat(pa-dashboard-demo):=20task-001=20?= =?UTF-8?q?=E2=80=94=20DemoProvider=20context=20with=20scene/case=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install motion + @xyflow/react deps. Create DemoProvider context that manages scene navigation (encounter/fleet/case) and selected case ID state. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/dashboard/package.json | 2 + .../src/components/demo/DemoProvider.tsx | 32 ++ .../demo/__tests__/DemoProvider.test.tsx | 36 +++ package-lock.json | 292 +++++++++++++++++- 4 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/src/components/demo/DemoProvider.tsx create mode 100644 apps/dashboard/src/components/demo/__tests__/DemoProvider.test.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index e5116c3..a48b4b0 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -28,6 +28,7 @@ "@tanstack/react-query": "^5.80.6", "@tanstack/react-router": "1.131.35", "@tanstack/router-plugin": "1.131.35", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "graphql": "^16.9.0", @@ -35,6 +36,7 @@ "html2canvas": "^1.4.1", "jspdf": "^4.1.0", "lucide-react": "^0.513.0", + "motion": "^12.36.0", "pdf-lib": "^1.17.1", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/apps/dashboard/src/components/demo/DemoProvider.tsx b/apps/dashboard/src/components/demo/DemoProvider.tsx new file mode 100644 index 0000000..93b69b1 --- /dev/null +++ b/apps/dashboard/src/components/demo/DemoProvider.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useState } from 'react'; +import type { ReactNode } from 'react'; + +export type DemoScene = 'encounter' | 'fleet' | 'case'; + +interface DemoContextValue { + scene: DemoScene; + setScene: (scene: DemoScene) => void; + selectedCaseId: string | null; + setSelectedCaseId: (id: string | null) => void; +} + +const DemoContext = createContext(null); + +export function DemoProvider({ children }: { children: ReactNode }) { + const [scene, setScene] = useState('encounter'); + const [selectedCaseId, setSelectedCaseId] = useState(null); + + return ( + + {children} + + ); +} + +export function useDemoContext(): DemoContextValue { + const ctx = useContext(DemoContext); + if (!ctx) { + throw new Error('useDemoContext must be used within a DemoProvider'); + } + return ctx; +} diff --git a/apps/dashboard/src/components/demo/__tests__/DemoProvider.test.tsx b/apps/dashboard/src/components/demo/__tests__/DemoProvider.test.tsx new file mode 100644 index 0000000..cbe23a4 --- /dev/null +++ b/apps/dashboard/src/components/demo/__tests__/DemoProvider.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { DemoProvider, useDemoContext } from '../DemoProvider'; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe('DemoProvider', () => { + it('DemoProvider_DefaultScene_IsEncounter', () => { + const { result } = renderHook(() => useDemoContext(), { wrapper }); + expect(result.current.scene).toBe('encounter'); + }); + + it('DemoProvider_SetScene_UpdatesContext', () => { + const { result } = renderHook(() => useDemoContext(), { wrapper }); + act(() => { + result.current.setScene('fleet'); + }); + expect(result.current.scene).toBe('fleet'); + }); + + it('DemoProvider_SelectedCaseId_IsNullByDefault', () => { + const { result } = renderHook(() => useDemoContext(), { wrapper }); + expect(result.current.selectedCaseId).toBeNull(); + }); + + it('DemoProvider_SetSelectedCaseId_UpdatesContext', () => { + const { result } = renderHook(() => useDemoContext(), { wrapper }); + act(() => { + result.current.setSelectedCaseId('case-123'); + }); + expect(result.current.selectedCaseId).toBe('case-123'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 097a3da..a177f83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@tanstack/react-query": "^5.80.6", "@tanstack/react-router": "1.131.35", "@tanstack/router-plugin": "1.131.35", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "graphql": "^16.9.0", @@ -45,6 +46,7 @@ "html2canvas": "^1.4.1", "jspdf": "^4.1.0", "lucide-react": "^0.513.0", + "motion": "^12.36.0", "pdf-lib": "^1.17.1", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -4167,6 +4169,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4737,6 +4788,38 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -5365,6 +5448,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -5525,6 +5614,111 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", @@ -6606,6 +6800,33 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/framer-motion": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.36.0.tgz", + "integrity": "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.36.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs-extra": { "version": "11.3.4", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", @@ -7141,7 +7362,7 @@ "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "type": "opencollective", @@ -8534,6 +8755,47 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.36.0.tgz", + "integrity": "sha512-5BMQuktYUX8aEByKWYx5tR4X3G08H2OMgp46wTxZ4o7CDDstyy4A0fe9RLNMjZiwvntCWGDvs16sC87/emz4Yw==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.36.0.tgz", + "integrity": "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -12014,6 +12276,34 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "shared/types": { "name": "@authscript/types", "version": "0.1.0", From 995262df0c1d50af8dbdc0ce2d5657431a3c9686 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:22:42 -0700 Subject: [PATCH 04/21] =?UTF-8?q?feat(pa-dashboard-demo):=20task-002=20?= =?UTF-8?q?=E2=80=94=20SceneNav=20pill=20navigation=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Horizontal pill-style nav bar with Encounter/Fleet/Case Detail buttons. Active pill shows teal fill, inactive shows outline. Includes Reset Demo button. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/demo/SceneNav.tsx | 52 ++++++++++++++++++ .../demo/__tests__/SceneNav.test.tsx | 54 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 apps/dashboard/src/components/demo/SceneNav.tsx create mode 100644 apps/dashboard/src/components/demo/__tests__/SceneNav.test.tsx diff --git a/apps/dashboard/src/components/demo/SceneNav.tsx b/apps/dashboard/src/components/demo/SceneNav.tsx new file mode 100644 index 0000000..88776c6 --- /dev/null +++ b/apps/dashboard/src/components/demo/SceneNav.tsx @@ -0,0 +1,52 @@ +import { RotateCcw } from 'lucide-react'; +import { useDemoContext } from './DemoProvider'; +import type { DemoScene } from './DemoProvider'; + +const PILLS: { scene: DemoScene; label: string }[] = [ + { scene: 'encounter', label: 'Encounter' }, + { scene: 'fleet', label: 'Fleet' }, + { scene: 'case', label: 'Case Detail' }, +]; + +export function SceneNav() { + const { scene, setScene, setSelectedCaseId } = useDemoContext(); + + function handleReset() { + setScene('encounter'); + setSelectedCaseId(null); + } + + return ( + + ); +} diff --git a/apps/dashboard/src/components/demo/__tests__/SceneNav.test.tsx b/apps/dashboard/src/components/demo/__tests__/SceneNav.test.tsx new file mode 100644 index 0000000..2d33528 --- /dev/null +++ b/apps/dashboard/src/components/demo/__tests__/SceneNav.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { DemoProvider } from '../DemoProvider'; +import { SceneNav } from '../SceneNav'; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderSceneNav() { + return render(, { wrapper }); +} + +describe('SceneNav', () => { + it('SceneNav_RendersThreePills_EncounterFleetCase', () => { + renderSceneNav(); + + expect(screen.getByRole('button', { name: /encounter/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /fleet/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /case detail/i })).toBeInTheDocument(); + }); + + it('SceneNav_ActiveScene_HasFilledStyle', () => { + renderSceneNav(); + + // Default scene is 'encounter', so it should have the active data attribute + const encounterBtn = screen.getByRole('button', { name: /encounter/i }); + expect(encounterBtn).toHaveAttribute('data-active', 'true'); + + const fleetBtn = screen.getByRole('button', { name: /fleet/i }); + expect(fleetBtn).toHaveAttribute('data-active', 'false'); + }); + + it('SceneNav_ClickPill_CallsSetScene', () => { + renderSceneNav(); + + const fleetBtn = screen.getByRole('button', { name: /fleet/i }); + fireEvent.click(fleetBtn); + + // After click, fleet should now be active + expect(fleetBtn).toHaveAttribute('data-active', 'true'); + + // And encounter should be inactive + const encounterBtn = screen.getByRole('button', { name: /encounter/i }); + expect(encounterBtn).toHaveAttribute('data-active', 'false'); + }); + + it('SceneNav_DemoControls_RendersResetButton', () => { + renderSceneNav(); + + expect(screen.getByRole('button', { name: /reset demo/i })).toBeInTheDocument(); + }); +}); From 24ed3448560d3f957e55bfed9993fd358121b0a0 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:23:52 -0700 Subject: [PATCH 05/21] =?UTF-8?q?feat(pa-dashboard-demo):=20task-003=20?= =?UTF-8?q?=E2=80=94=20SceneTransition=20wrapper=20and=20route=20scaffoldi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SceneTransition component using motion/react AnimatePresence for opacity fade transitions between scenes. Scaffold /fleet and /case/$caseId routes with SceneNav integration and placeholder content. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/demo/SceneTransition.tsx | 23 +++++++ .../demo/__tests__/SceneTransition.test.tsx | 68 +++++++++++++++++++ apps/dashboard/src/routeTree.gen.ts | 49 ++++++++++++- apps/dashboard/src/routes/case.$caseId.tsx | 23 +++++++ apps/dashboard/src/routes/fleet.tsx | 20 ++++++ 5 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/src/components/demo/SceneTransition.tsx create mode 100644 apps/dashboard/src/components/demo/__tests__/SceneTransition.test.tsx create mode 100644 apps/dashboard/src/routes/case.$caseId.tsx create mode 100644 apps/dashboard/src/routes/fleet.tsx diff --git a/apps/dashboard/src/components/demo/SceneTransition.tsx b/apps/dashboard/src/components/demo/SceneTransition.tsx new file mode 100644 index 0000000..cfa8503 --- /dev/null +++ b/apps/dashboard/src/components/demo/SceneTransition.tsx @@ -0,0 +1,23 @@ +import { AnimatePresence, motion } from 'motion/react'; +import type { ReactNode } from 'react'; + +interface SceneTransitionProps { + sceneKey: string; + children: ReactNode; +} + +export function SceneTransition({ sceneKey, children }: SceneTransitionProps) { + return ( + + + {children} + + + ); +} diff --git a/apps/dashboard/src/components/demo/__tests__/SceneTransition.test.tsx b/apps/dashboard/src/components/demo/__tests__/SceneTransition.test.tsx new file mode 100644 index 0000000..faf4637 --- /dev/null +++ b/apps/dashboard/src/components/demo/__tests__/SceneTransition.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SceneTransition } from '../SceneTransition'; + +// Mock motion/react to avoid animation complexity in tests +vi.mock('motion/react', () => { + const React = require('react'); + + const AnimatePresence = ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ); + + const motionDiv = React.forwardRef( + ( + { + children, + ...props + }: { children?: React.ReactNode; [key: string]: unknown }, + ref: React.Ref, + ) => ( +
+ {children} +
+ ), + ); + motionDiv.displayName = 'motion.div'; + + return { + AnimatePresence, + motion: { div: motionDiv }, + }; +}); + +describe('SceneTransition', () => { + it('SceneTransition_RendersActiveScene_WithChildren', () => { + render( + +
Encounter content
+
, + ); + + expect(screen.getByText('Encounter content')).toBeInTheDocument(); + }); + + it('SceneTransition_SceneChange_AnimatesTransition', () => { + const { rerender } = render( + +
Scene A
+
, + ); + + // AnimatePresence should be rendered wrapping the content + expect(screen.getByTestId('animate-presence')).toBeInTheDocument(); + expect(screen.getByTestId('motion-div')).toBeInTheDocument(); + + // Re-render with new scene key + rerender( + +
Scene B
+
, + ); + + // New content should render + expect(screen.getByText('Scene B')).toBeInTheDocument(); + // AnimatePresence should still be wrapping + expect(screen.getByTestId('animate-presence')).toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 280bc99..f2e829a 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -11,8 +11,10 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SmartLaunchRouteImport } from './routes/smart-launch' import { Route as HelpRouteImport } from './routes/help' +import { Route as FleetRouteImport } from './routes/fleet' import { Route as EhrDemoRouteImport } from './routes/ehr-demo' import { Route as IndexRouteImport } from './routes/index' +import { Route as CaseCaseIdRouteImport } from './routes/case.$caseId' import { Route as AnalysisTransactionIdRouteImport } from './routes/analysis.$transactionId' const SmartLaunchRoute = SmartLaunchRouteImport.update({ @@ -25,6 +27,11 @@ const HelpRoute = HelpRouteImport.update({ path: '/help', getParentRoute: () => rootRouteImport, } as any) +const FleetRoute = FleetRouteImport.update({ + id: '/fleet', + path: '/fleet', + getParentRoute: () => rootRouteImport, +} as any) const EhrDemoRoute = EhrDemoRouteImport.update({ id: '/ehr-demo', path: '/ehr-demo', @@ -35,6 +42,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const CaseCaseIdRoute = CaseCaseIdRouteImport.update({ + id: '/case/$caseId', + path: '/case/$caseId', + getParentRoute: () => rootRouteImport, +} as any) const AnalysisTransactionIdRoute = AnalysisTransactionIdRouteImport.update({ id: '/analysis/$transactionId', path: '/analysis/$transactionId', @@ -44,50 +56,69 @@ const AnalysisTransactionIdRoute = AnalysisTransactionIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/ehr-demo': typeof EhrDemoRoute + '/fleet': typeof FleetRoute '/help': typeof HelpRoute '/smart-launch': typeof SmartLaunchRoute '/analysis/$transactionId': typeof AnalysisTransactionIdRoute + '/case/$caseId': typeof CaseCaseIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/ehr-demo': typeof EhrDemoRoute + '/fleet': typeof FleetRoute '/help': typeof HelpRoute '/smart-launch': typeof SmartLaunchRoute '/analysis/$transactionId': typeof AnalysisTransactionIdRoute + '/case/$caseId': typeof CaseCaseIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/ehr-demo': typeof EhrDemoRoute + '/fleet': typeof FleetRoute '/help': typeof HelpRoute '/smart-launch': typeof SmartLaunchRoute '/analysis/$transactionId': typeof AnalysisTransactionIdRoute + '/case/$caseId': typeof CaseCaseIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/ehr-demo' + | '/fleet' | '/help' | '/smart-launch' | '/analysis/$transactionId' + | '/case/$caseId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/ehr-demo' | '/help' | '/smart-launch' | '/analysis/$transactionId' + to: + | '/' + | '/ehr-demo' + | '/fleet' + | '/help' + | '/smart-launch' + | '/analysis/$transactionId' + | '/case/$caseId' id: | '__root__' | '/' | '/ehr-demo' + | '/fleet' | '/help' | '/smart-launch' | '/analysis/$transactionId' + | '/case/$caseId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute EhrDemoRoute: typeof EhrDemoRoute + FleetRoute: typeof FleetRoute HelpRoute: typeof HelpRoute SmartLaunchRoute: typeof SmartLaunchRoute AnalysisTransactionIdRoute: typeof AnalysisTransactionIdRoute + CaseCaseIdRoute: typeof CaseCaseIdRoute } declare module '@tanstack/react-router' { @@ -106,6 +137,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HelpRouteImport parentRoute: typeof rootRouteImport } + '/fleet': { + id: '/fleet' + path: '/fleet' + fullPath: '/fleet' + preLoaderRoute: typeof FleetRouteImport + parentRoute: typeof rootRouteImport + } '/ehr-demo': { id: '/ehr-demo' path: '/ehr-demo' @@ -120,6 +158,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/case/$caseId': { + id: '/case/$caseId' + path: '/case/$caseId' + fullPath: '/case/$caseId' + preLoaderRoute: typeof CaseCaseIdRouteImport + parentRoute: typeof rootRouteImport + } '/analysis/$transactionId': { id: '/analysis/$transactionId' path: '/analysis/$transactionId' @@ -133,9 +178,11 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, EhrDemoRoute: EhrDemoRoute, + FleetRoute: FleetRoute, HelpRoute: HelpRoute, SmartLaunchRoute: SmartLaunchRoute, AnalysisTransactionIdRoute: AnalysisTransactionIdRoute, + CaseCaseIdRoute: CaseCaseIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/dashboard/src/routes/case.$caseId.tsx b/apps/dashboard/src/routes/case.$caseId.tsx new file mode 100644 index 0000000..2adc820 --- /dev/null +++ b/apps/dashboard/src/routes/case.$caseId.tsx @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { DemoProvider } from '@/components/demo/DemoProvider'; +import { SceneNav } from '@/components/demo/SceneNav'; + +export const Route = createFileRoute('/case/$caseId')({ + component: CaseDetailPage, +}); + +function CaseDetailPage() { + const { caseId } = Route.useParams(); + + return ( + + +
+

+ Case Detail +

+

Case ID: {caseId}

+
+
+ ); +} diff --git a/apps/dashboard/src/routes/fleet.tsx b/apps/dashboard/src/routes/fleet.tsx new file mode 100644 index 0000000..fd02511 --- /dev/null +++ b/apps/dashboard/src/routes/fleet.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { DemoProvider } from '@/components/demo/DemoProvider'; +import { SceneNav } from '@/components/demo/SceneNav'; + +export const Route = createFileRoute('/fleet')({ + component: FleetPage, +}); + +function FleetPage() { + return ( + + +
+

+ Prior Authorization Command Center +

+
+
+ ); +} From 1ef87ce7d5d83149523df8182dc526117586ed83 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:24:04 -0700 Subject: [PATCH 06/21] =?UTF-8?q?feat(pa-dashboard-demo):=20task-017=20?= =?UTF-8?q?=E2=80=94=20backend=20seed=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SeedDemoData static class that generates 48 deterministic PARequestModel instances for the PA dashboard demo, distributed across all 7 Athena sandbox patients with realistic clinical content, procedures (MRI Lumbar, MRI Cervical, CT Abdomen/Pelvis), and status distribution (6 processing, 8 ready, 15 submitted, 8 waiting_for_insurance, 9 approved, 2 denied). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Data/SeedDemoDataTests.cs | 108 +++++++++ apps/gateway/Gateway.API/Data/SeedDemoData.cs | 225 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 apps/gateway/Gateway.API.Tests/Data/SeedDemoDataTests.cs create mode 100644 apps/gateway/Gateway.API/Data/SeedDemoData.cs diff --git a/apps/gateway/Gateway.API.Tests/Data/SeedDemoDataTests.cs b/apps/gateway/Gateway.API.Tests/Data/SeedDemoDataTests.cs new file mode 100644 index 0000000..84095e9 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Data/SeedDemoDataTests.cs @@ -0,0 +1,108 @@ +// ============================================================================= +// +// Copyright (c) Levelup Software. All rights reserved. +// +// ============================================================================= + +namespace Gateway.API.Tests.Data; + +using Gateway.API.Data; + +/// +/// Tests for static seed generator. +/// +public class SeedDemoDataTests +{ + [Test] + public async Task SeedDemoData_GeneratesCorrectCount_48Requests() + { + var requests = SeedDemoData.GenerateSeedRequests(); + + await Assert.That(requests.Count).IsEqualTo(48); + } + + [Test] + public async Task SeedDemoData_DistributesStatuses_PerSpec() + { + var requests = SeedDemoData.GenerateSeedRequests(); + + var statusCounts = requests + .GroupBy(r => r.Status) + .ToDictionary(g => g.Key, g => g.Count()); + + await Assert.That(statusCounts["processing"]).IsEqualTo(6); + await Assert.That(statusCounts["ready"]).IsEqualTo(8); + await Assert.That(statusCounts["submitted"]).IsEqualTo(15); + await Assert.That(statusCounts["waiting_for_insurance"]).IsEqualTo(8); + await Assert.That(statusCounts["approved"]).IsEqualTo(9); + await Assert.That(statusCounts["denied"]).IsEqualTo(2); + } + + [Test] + public async Task SeedDemoData_UsesAllTestPatients() + { + var requests = SeedDemoData.GenerateSeedRequests(); + + var patientIds = requests.Select(r => r.PatientId).Distinct().ToList(); + + await Assert.That(patientIds).Contains("60178"); + await Assert.That(patientIds).Contains("60179"); + await Assert.That(patientIds).Contains("60180"); + await Assert.That(patientIds).Contains("60181"); + await Assert.That(patientIds).Contains("60182"); + await Assert.That(patientIds).Contains("60183"); + await Assert.That(patientIds).Contains("60184"); + } + + [Test] + public async Task SeedDemoData_IncludesMultipleProcedures() + { + var requests = SeedDemoData.GenerateSeedRequests(); + + var cptCodes = requests.Select(r => r.ProcedureCode).Distinct().ToList(); + + await Assert.That(cptCodes).Contains("72148"); + await Assert.That(cptCodes).Contains("72141"); + await Assert.That(cptCodes).Contains("74177"); + } + + [Test] + public async Task SeedDemoData_IncludesMultiplePayers() + { + var requests = SeedDemoData.GenerateSeedRequests(); + + var payers = requests.Select(r => r.Payer).Distinct().ToList(); + + await Assert.That(payers).Contains("Aetna"); + await Assert.That(payers).Contains("United Healthcare"); + await Assert.That(payers).Contains("Cigna"); + } + + [Test] + public async Task SeedDemoData_ConfidenceScoresInRange() + { + var requests = SeedDemoData.GenerateSeedRequests(); + + foreach (var request in requests) + { + await Assert.That(request.Confidence).IsGreaterThanOrEqualTo(60); + await Assert.That(request.Confidence).IsLessThanOrEqualTo(98); + } + } + + [Test] + public async Task SeedDemoData_TimestampsSpreadOverWeek() + { + var requests = SeedDemoData.GenerateSeedRequests(); + + var timestamps = requests + .Select(r => DateTimeOffset.Parse(r.CreatedAt)) + .ToList(); + + var earliest = timestamps.Min(); + var latest = timestamps.Max(); + var span = latest - earliest; + + await Assert.That(span.TotalDays).IsGreaterThanOrEqualTo(6); + } +} diff --git a/apps/gateway/Gateway.API/Data/SeedDemoData.cs b/apps/gateway/Gateway.API/Data/SeedDemoData.cs new file mode 100644 index 0000000..60b3784 --- /dev/null +++ b/apps/gateway/Gateway.API/Data/SeedDemoData.cs @@ -0,0 +1,225 @@ +// ============================================================================= +// +// Copyright (c) Levelup Software. All rights reserved. +// +// ============================================================================= + +namespace Gateway.API.Data; + +using Gateway.API.GraphQL.Models; + +/// +/// Generates deterministic demo seed data for the PA dashboard. +/// Produces 48 instances with realistic clinical content +/// distributed across the 7 Athena sandbox test patients. +/// +public static class SeedDemoData +{ + // ── Patient fixtures ──────────────────────────────────────────────────── + private static readonly (string Id, string FhirId, string Name, string Mrn, string Dob, string MemberId, string Payer, string Address, string Phone)[] Patients = + [ + ("60178", "a-195900.E-60178", "Donna Sandboxtest", "MRN-60178", "February 1, 1984", "AET-100178", "Aetna", "42 Benefit St, Providence, RI 02903", "(401) 555-0178"), + ("60179", "a-195900.E-60179", "Eleana Sandboxtest", "MRN-60179", "February 27, 2015", "AET-100179", "Aetna", "321 Elm St, Seattle, WA 98101", "(206) 555-0179"), + ("60180", "a-195900.E-60180", "Frankie Sandboxtest", "MRN-60180", "June 15, 1972", "UHC-200180", "United Healthcare", "100 Main St, Denver, CO 80202", "(303) 555-0180"), + ("60181", "a-195900.E-60181", "Anna Testpt", "MRN-60181", "December 17, 1995", "CIG-300181", "Cigna", "55 Water St, New York, NY 10004", "(212) 555-0181"), + ("60182", "a-195900.E-60182", "Rebecca Sandbox-Test","MRN-60182", "March 10, 1990", "AET-100182", "Aetna", "789 Pine Rd, Austin, TX 78701", "(512) 555-0182"), + ("60183", "a-195900.E-60183", "Gary Sandboxtest", "MRN-60183", "April 18, 1948", "AET-100183", "Aetna", "456 Oak Ave, Chicago, IL 60601", "(312) 555-0183"), + ("60184", "a-195900.E-60184", "Dorrie Sandboxtest", "MRN-60184", "November 23, 1949", "UHC-200184", "United Healthcare", "100 Federal St, Boston, MA 02110", "(617) 555-0184"), + ]; + + // ── Procedure fixtures ────────────────────────────────────────────────── + private static readonly (string Code, string Name, string[] Diagnoses, string[] DiagnosisCodes)[] Procedures = + [ + ("72148", "MRI Lumbar Spine w/o Contrast", ["Low back pain", "Lumbago with sciatica"], ["M54.5", "M54.41"]), + ("72141", "MRI Cervical Spine w/o Contrast", ["Cervical disc disorder", "Cervicalgia"], ["M50.12", "M54.2"]), + ("74177", "CT Abdomen/Pelvis w/ Contrast", ["Gallstone", "Abdominal pain"], ["K80.20", "R10.9"]), + ]; + + // ── Provider fixtures ─────────────────────────────────────────────────── + private static readonly (string Id, string Name, string Npi)[] Providers = + [ + ("DR001", "Dr. Kelli Smith", "1234567890"), + ("DR002", "Dr. Robert Kim", "0987654321"), + ("DR003", "Dr. Lisa Thompson", "1122334455"), + ("DR004", "Dr. Sarah Mitchell", "5566778899"), + ]; + + // ── Clinical summary templates (by procedure) ─────────────────────────── + private static readonly string[][] ClinicalSummaries = + [ + // 72148 — MRI Lumbar + [ + "Patient presents with chronic low back pain radiating to left lower extremity for 6 weeks. Conservative treatment with NSAIDs and physical therapy for 4 weeks without improvement. Straight-leg raise positive at 40 degrees.", + "Acute onset low back pain with progressive bilateral lower extremity numbness. History of lumbar disc herniation. MRI indicated to evaluate for recurrent disc pathology.", + "Patient with worsening low back pain and radiculopathy. Failed 6 weeks of conservative management including physical therapy, NSAIDs, and epidural steroid injection. Neurological exam notable for diminished ankle reflex.", + "Low back pain with sciatica unresponsive to 8 weeks of conservative care. Progressive motor weakness noted in L5 distribution. Urgent imaging indicated.", + ], + // 72141 — MRI Cervical + [ + "Patient with neck pain and right upper extremity radiculopathy for 4 weeks. Failed conservative treatment with NSAIDs and cervical collar. Weakness noted in C6 distribution.", + "Chronic cervicalgia with myelopathic symptoms including gait instability and hand clumsiness. MRI cervical spine indicated to evaluate for spinal cord compression.", + "Neck pain with progressive right arm weakness and paresthesias. EMG suggests C5-C6 radiculopathy. Advanced imaging warranted.", + ], + // 74177 — CT Abdomen/Pelvis + [ + "Right upper quadrant pain with nausea and elevated liver enzymes. Ultrasound showed cholelithiasis. CT indicated for surgical planning and evaluation of biliary anatomy.", + "Diffuse abdominal pain with intermittent episodes for 3 months. Initial workup including labs and ultrasound inconclusive. CT with contrast for further evaluation.", + "Patient with known gallstones presenting with acute right upper quadrant pain, fever, and leukocytosis. CT requested to evaluate for complications including cholecystitis or biliary obstruction.", + ], + ]; + + // ── Criteria templates ────────────────────────────────────────────────── + private static readonly CriterionModel[][] CriteriaTemplates = + [ + // Template 0: All MET (for approved/submitted) + [ + new() { Met = true, Label = "Medical necessity documented", Reason = "Clinical documentation supports medical necessity for the requested procedure" }, + new() { Met = true, Label = "Valid diagnosis code present", Reason = "ICD-10 code is an established indication for this procedure" }, + new() { Met = true, Label = "Conservative therapy attempted", Reason = "Patient has completed appropriate conservative management without adequate improvement" }, + new() { Met = true, Label = "Clinical rationale documented", Reason = "Ordering provider has documented clear clinical rationale with supporting exam findings" }, + ], + // Template 1: Mostly MET, one unclear (for ready/waiting) + [ + new() { Met = true, Label = "Medical necessity documented", Reason = "Clinical documentation supports medical necessity" }, + new() { Met = true, Label = "Valid diagnosis code present", Reason = "Diagnosis code supports the requested imaging study" }, + new() { Met = null, Label = "Conservative therapy attempted", Reason = "Documentation of prior conservative treatment is incomplete or unclear" }, + new() { Met = true, Label = "No duplicate imaging in 12 months", Reason = "No prior imaging of same anatomical region found" }, + ], + // Template 2: Mixed with NOT_MET (for denied) + [ + new() { Met = true, Label = "Medical necessity documented", Reason = "Basic clinical need is documented" }, + new() { Met = false, Label = "Conservative therapy attempted", Reason = "No documentation of conservative treatment trial of at least 4 weeks" }, + new() { Met = false, Label = "Clinical rationale documented", Reason = "Insufficient clinical rationale — no neurological exam findings or red flags documented" }, + new() { Met = true, Label = "Valid diagnosis code present", Reason = "Diagnosis code is valid but does not fully support the imaging request" }, + new() { Met = false, Label = "Prior imaging reviewed", Reason = "Recent imaging of same region exists but was not referenced in the order" }, + ], + // Template 3: 5 criteria, all MET (for high-confidence approved) + [ + new() { Met = true, Label = "Medical necessity documented", Reason = "Strong clinical indication with supporting documentation" }, + new() { Met = true, Label = "Valid diagnosis code present", Reason = "ICD-10 code is a primary indication per LCD guidelines" }, + new() { Met = true, Label = "Conservative therapy exhausted", Reason = "Completed full course of conservative management over recommended duration" }, + new() { Met = true, Label = "Red flag screening completed", Reason = "Neurological exam performed with documented findings supporting imaging" }, + new() { Met = true, Label = "No duplicate imaging", Reason = "No prior imaging of same anatomical region in the past 12 months" }, + ], + // Template 4: 3 criteria, all MET (for submitted) + [ + new() { Met = true, Label = "Medical necessity documented", Reason = "Procedure is medically necessary based on clinical presentation" }, + new() { Met = true, Label = "Valid diagnosis code present", Reason = "Diagnosis code is appropriate for the requested procedure" }, + new() { Met = true, Label = "Supporting documentation complete", Reason = "All required supporting documentation has been provided" }, + ], + ]; + + // ── Status distribution (total = 48) ──────────────────────────────────── + // processing: 6, ready: 8, submitted: 15, waiting_for_insurance: 8, approved: 9, denied: 2 + private static readonly (string Status, int Count)[] StatusDistribution = + [ + ("processing", 6), + ("ready", 8), + ("submitted", 15), + ("waiting_for_insurance", 8), + ("approved", 9), + ("denied", 2), + ]; + + /// + /// Generates 48 deterministic demo instances. + /// Uses a fixed random seed for reproducibility. + /// + /// A list of 48 PA request models with varied statuses, patients, and procedures. + public static List GenerateSeedRequests() + { + var rng = new Random(42); // deterministic seed + var now = DateTimeOffset.UtcNow; + var results = new List(48); + var index = 0; + + foreach (var (status, count) in StatusDistribution) + { + for (var i = 0; i < count; i++) + { + var patient = Patients[index % Patients.Length]; + var procedure = Procedures[index % Procedures.Length]; + var provider = Providers[index % Providers.Length]; + var diagnosisIdx = rng.Next(procedure.Diagnoses.Length); + + // Spread createdAt over the last 7 days + var daysAgo = 7.0 * index / 47.0; // 0..7 evenly spread + var createdAt = now.AddDays(-daysAgo).AddMinutes(-rng.Next(0, 60)); + + // Confidence: 60-98 range + var confidence = status == "denied" + ? 60 + rng.Next(0, 6) // 60-65 for denied + : 60 + rng.Next(0, 39); // 60-98 for others + + // Select criteria template based on status + var criteriaTemplate = status switch + { + "denied" => CriteriaTemplates[2], + "approved" => rng.Next(2) == 0 ? CriteriaTemplates[0] : CriteriaTemplates[3], + "processing" => CriteriaTemplates[4], + _ => rng.Next(3) == 0 ? CriteriaTemplates[1] : CriteriaTemplates[0], + }; + + // Build timestamp progression + var readyAt = status is "processing" + ? (DateTimeOffset?)null + : createdAt.AddMinutes(rng.Next(5, 30)); + + var submittedAt = status is "processing" or "ready" + ? (DateTimeOffset?)null + : readyAt!.Value.AddMinutes(rng.Next(2, 60)); + + var reviewTimeSeconds = submittedAt.HasValue + ? rng.Next(30, 300) + : 0; + + // Clinical summary selection + var summaryPool = ClinicalSummaries[index % Procedures.Length]; + var summary = summaryPool[index % summaryPool.Length]; + + var serviceDate = now.AddDays(rng.Next(1, 30)).ToString("MMMM d, yyyy"); + + results.Add(new PARequestModel + { + Id = $"PA-SEED-{index + 1:D3}", + PatientId = patient.Id, + FhirPatientId = patient.FhirId, + Patient = new PatientModel + { + Id = patient.Id, + Name = patient.Name, + Mrn = patient.Mrn, + Dob = patient.Dob, + MemberId = patient.MemberId, + Payer = patient.Payer, + Address = patient.Address, + Phone = patient.Phone, + }, + ProcedureCode = procedure.Code, + ProcedureName = procedure.Name, + Diagnosis = procedure.Diagnoses[diagnosisIdx], + DiagnosisCode = procedure.DiagnosisCodes[diagnosisIdx], + Payer = patient.Payer, + ProviderId = provider.Id, + Provider = provider.Name, + ProviderNpi = provider.Npi, + ServiceDate = serviceDate, + PlaceOfService = "Outpatient", + ClinicalSummary = summary, + Status = status, + Confidence = confidence, + Criteria = criteriaTemplate.ToList(), + CreatedAt = createdAt.ToString("o"), + UpdatedAt = (submittedAt ?? readyAt ?? createdAt).ToString("o"), + ReadyAt = readyAt?.ToString("o"), + SubmittedAt = submittedAt?.ToString("o"), + ReviewTimeSeconds = reviewTimeSeconds, + }); + + index++; + } + } + + return results; + } +} From 8f8fa0139efb02347c7e4ff335d21cd0365bf450 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:28:07 -0700 Subject: [PATCH 07/21] feat(pa-dashboard-demo): task-004 -- fleet seed data and KPICards component Add deterministic fleet seed data generator (48 PA requests across 6 statuses) and KPI cards component with color-coded borders and active filter highlighting. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/fleet/KPICards.tsx | 99 +++++++++++++ .../fleet/__tests__/KPICards.test.tsx | 54 ++++++++ .../src/lib/__tests__/fleetSeedData.test.ts | 55 ++++++++ apps/dashboard/src/lib/fleetSeedData.ts | 131 ++++++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 apps/dashboard/src/components/fleet/KPICards.tsx create mode 100644 apps/dashboard/src/components/fleet/__tests__/KPICards.test.tsx create mode 100644 apps/dashboard/src/lib/__tests__/fleetSeedData.test.ts create mode 100644 apps/dashboard/src/lib/fleetSeedData.ts diff --git a/apps/dashboard/src/components/fleet/KPICards.tsx b/apps/dashboard/src/components/fleet/KPICards.tsx new file mode 100644 index 0000000..1a2adb0 --- /dev/null +++ b/apps/dashboard/src/components/fleet/KPICards.tsx @@ -0,0 +1,99 @@ +import { cn } from '@/lib/utils'; + +export interface KPIStats { + total: number; + processing: number; + ready: number; + submitted: number; + approved: number; + denied: number; +} + +interface KPICardsProps { + stats: KPIStats; + activeFilter: string | null; + onFilter: (status: string) => void; +} + +interface CardConfig { + key: keyof KPIStats; + label: string; + borderColor: string; + activeBg: string; + activeBorder: string; +} + +const CARDS: CardConfig[] = [ + { + key: 'total', + label: 'Total Cases', + borderColor: 'border-t-teal-500', + activeBg: 'bg-teal-50 dark:bg-teal-950/30', + activeBorder: 'border-4 border-teal-500', + }, + { + key: 'processing', + label: 'Processing', + borderColor: 'border-t-blue-500', + activeBg: 'bg-blue-50 dark:bg-blue-950/30', + activeBorder: 'border-4 border-blue-500', + }, + { + key: 'ready', + label: 'Ready', + borderColor: 'border-t-purple-500', + activeBg: 'bg-purple-50 dark:bg-purple-950/30', + activeBorder: 'border-4 border-purple-500', + }, + { + key: 'submitted', + label: 'Submitted', + borderColor: 'border-t-amber-500', + activeBg: 'bg-amber-50 dark:bg-amber-950/30', + activeBorder: 'border-4 border-amber-500', + }, + { + key: 'approved', + label: 'Approved', + borderColor: 'border-t-green-500', + activeBg: 'bg-green-50 dark:bg-green-950/30', + activeBorder: 'border-4 border-green-500', + }, + { + key: 'denied', + label: 'Denied', + borderColor: 'border-t-red-500', + activeBg: 'bg-red-50 dark:bg-red-950/30', + activeBorder: 'border-4 border-red-500', + }, +]; + +export function KPICards({ stats, activeFilter, onFilter }: KPICardsProps) { + return ( +
+ {CARDS.map((card) => { + const isActive = activeFilter === card.key; + return ( + + ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/fleet/__tests__/KPICards.test.tsx b/apps/dashboard/src/components/fleet/__tests__/KPICards.test.tsx new file mode 100644 index 0000000..2fb3c8c --- /dev/null +++ b/apps/dashboard/src/components/fleet/__tests__/KPICards.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { KPICards } from '../KPICards'; + +const mockStats = { + total: 48, + processing: 6, + ready: 8, + submitted: 15, + approved: 9, + denied: 2, +}; + +describe('KPICards', () => { + it('KPICards_RendersSixCards', () => { + render( + , + ); + const cards = screen.getAllByTestId(/^kpi-card-/); + expect(cards).toHaveLength(6); + }); + + it('KPICards_DisplaysValues_FromStats', () => { + render( + , + ); + expect(screen.getByText('48')).toBeInTheDocument(); + expect(screen.getByText('6')).toBeInTheDocument(); + expect(screen.getByText('8')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); + expect(screen.getByText('9')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('KPICards_ClickCard_CallsOnFilter', () => { + const onFilter = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId('kpi-card-ready')); + expect(onFilter).toHaveBeenCalledWith('ready'); + }); + + it('KPICards_ActiveFilter_HasHighlightedBorder', () => { + render( + , + ); + const activeCard = screen.getByTestId('kpi-card-ready'); + expect(activeCard.className).toMatch(/border-4|ring-2/); + + const inactiveCard = screen.getByTestId('kpi-card-total'); + expect(inactiveCard.className).not.toMatch(/border-4|ring-2/); + }); +}); diff --git a/apps/dashboard/src/lib/__tests__/fleetSeedData.test.ts b/apps/dashboard/src/lib/__tests__/fleetSeedData.test.ts new file mode 100644 index 0000000..761df34 --- /dev/null +++ b/apps/dashboard/src/lib/__tests__/fleetSeedData.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { generateFleetData } from '../fleetSeedData'; + +describe('generateFleetData', () => { + it('fleetSeedData_Returns48Cases', () => { + const data = generateFleetData(); + expect(data).toHaveLength(48); + }); + + it('fleetSeedData_DistributesAcrossStatuses', () => { + const data = generateFleetData(); + const counts = data.reduce( + (acc, r) => { + acc[r.status] = (acc[r.status] || 0) + 1; + return acc; + }, + {} as Record, + ); + expect(counts['processing']).toBe(6); + expect(counts['ready']).toBe(8); + expect(counts['submitted']).toBe(15); + expect(counts['waiting_for_insurance']).toBe(8); + expect(counts['approved']).toBe(9); + expect(counts['denied']).toBe(2); + }); + + it('fleetSeedData_UsesAllTestPatients', () => { + const data = generateFleetData(); + const patientNames = new Set(data.map((r) => r.patient.name)); + expect(patientNames.size).toBe(7); + expect(patientNames).toContain('Donna Sandbox'); + expect(patientNames).toContain('Eleana Sandbox'); + expect(patientNames).toContain('Frankie Sandbox'); + expect(patientNames).toContain('Anna Sandbox'); + expect(patientNames).toContain('Rebecca Sandbox'); + expect(patientNames).toContain('Gary Sandbox'); + expect(patientNames).toContain('Dorrie Sandbox'); + }); + + it('fleetSeedData_IncludesMultiplePayers', () => { + const data = generateFleetData(); + const payers = new Set(data.map((r) => r.payer)); + expect(payers).toContain('Aetna'); + expect(payers).toContain('United Healthcare'); + expect(payers).toContain('Cigna'); + }); + + it('fleetSeedData_ConfidenceScoresInRange', () => { + const data = generateFleetData(); + for (const request of data) { + expect(request.confidence).toBeGreaterThanOrEqual(60); + expect(request.confidence).toBeLessThanOrEqual(98); + } + }); +}); diff --git a/apps/dashboard/src/lib/fleetSeedData.ts b/apps/dashboard/src/lib/fleetSeedData.ts new file mode 100644 index 0000000..b544c98 --- /dev/null +++ b/apps/dashboard/src/lib/fleetSeedData.ts @@ -0,0 +1,131 @@ +/** + * Deterministic seed data for the Fleet Dashboard demo. + * Generates 48 PA requests distributed across 6 statuses using + * the 7 Athena test patients, 3 CPT codes, and 3 payers. + */ + +import { ATHENA_TEST_PATIENTS } from './patients'; +import type { PARequest, Patient, Criterion } from '@/api/graphqlService'; + +/** Extended status set for fleet demo (superset of backend PARequest.status) */ +export type FleetStatus = + | 'processing' + | 'ready' + | 'submitted' + | 'waiting_for_insurance' + | 'approved' + | 'denied'; + +/** PARequest with fleet-specific status */ +export type FleetPARequest = Omit & { + status: FleetStatus; +}; + +const PROCEDURES = [ + { code: '72148', name: 'MRI Lumbar Spine without Contrast' }, + { code: '72141', name: 'MRI Cervical Spine without Contrast' }, + { code: '74177', name: 'CT Abdomen and Pelvis with Contrast' }, +] as const; + +const PAYERS = ['Aetna', 'United Healthcare', 'Cigna'] as const; + +const DIAGNOSES = [ + { code: 'M54.5', name: 'Low back pain' }, + { code: 'M54.2', name: 'Cervicalgia' }, + { code: 'R10.9', name: 'Unspecified abdominal pain' }, +] as const; + +/** + * Status distribution: 48 total + * processing=6, ready=8, submitted=15, waiting_for_insurance=8, approved=9, denied=2 + */ +const STATUS_DISTRIBUTION: FleetStatus[] = [ + // 6 processing + ...Array(6).fill('processing'), + // 8 ready + ...Array(8).fill('ready'), + // 15 submitted + ...Array(15).fill('submitted'), + // 8 waiting_for_insurance + ...Array(8).fill('waiting_for_insurance'), + // 9 approved + ...Array(9).fill('approved'), + // 2 denied + ...Array(2).fill('denied'), +]; + +function makeCriteria(index: number): Criterion[] { + const met = index % 3 !== 2; + return [ + { met: true, label: 'Valid ICD-10 diagnosis', reason: 'Diagnosis code is covered' }, + { met, label: 'Prior conservative treatment', reason: met ? 'Documented' : 'Not documented' }, + { met: true, label: 'Clinical documentation', reason: 'Encounter note available' }, + ]; +} + +/** Deterministic confidence: 60-98 based on index */ +function computeConfidence(index: number): number { + return 60 + (index * 7) % 39; // yields values 60..98 deterministically +} + +/** Base date for timestamp generation */ +const BASE_DATE = new Date('2026-03-10T08:00:00Z'); + +function makeTimestamp(index: number, offsetHours: number): string { + const d = new Date(BASE_DATE.getTime() + ((index * 3 + offsetHours) % 168) * 3600000); + return d.toISOString(); +} + +export function generateFleetData(): FleetPARequest[] { + const patients = ATHENA_TEST_PATIENTS; + + return STATUS_DISTRIBUTION.map((status, i): FleetPARequest => { + const patient = patients[i % patients.length]; + const procedure = PROCEDURES[i % PROCEDURES.length]; + const payer = PAYERS[i % PAYERS.length]; + const diagnosis = DIAGNOSES[i % DIAGNOSES.length]; + const confidence = computeConfidence(i); + + const createdAt = makeTimestamp(i, 0); + const updatedAt = makeTimestamp(i, 2); + + const patientModel: Patient = { + id: patient.id, + name: patient.name, + mrn: patient.mrn, + dob: patient.dob, + memberId: patient.memberId, + payer: patient.payer, + address: patient.address, + phone: patient.phone, + }; + + return { + id: `fleet-${String(i + 1).padStart(3, '0')}`, + patientId: patient.patientId, + fhirPatientId: patient.fhirId, + patient: patientModel, + procedureCode: procedure.code, + procedureName: procedure.name, + diagnosis: diagnosis.name, + diagnosisCode: diagnosis.code, + payer: payer, + provider: 'Dr. Sarah Chen', + providerNpi: '1234567890', + serviceDate: '2026-03-15', + placeOfService: 'Office', + clinicalSummary: `Patient presents for ${procedure.name}`, + status, + confidence, + createdAt, + updatedAt, + readyAt: ['ready', 'submitted', 'waiting_for_insurance', 'approved', 'denied'].includes(status) + ? makeTimestamp(i, 4) + : null, + submittedAt: ['submitted', 'waiting_for_insurance', 'approved', 'denied'].includes(status) + ? makeTimestamp(i, 6) + : null, + criteria: makeCriteria(i), + }; + }); +} From a09be2f11bfe376e78d77e9d47946ef80ccc0452 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:28:50 -0700 Subject: [PATCH 08/21] feat(pa-dashboard-demo): task-008 -- enhanced demo data and ChartTabPanel component Add DEMO_CHART_DATA with problems (ICD codes), medications, allergies (NKDA), imaging history, and lab results. Create ChartTabPanel component that renders tab-specific content for the sidebar chart browser. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/ehr/ChartTabPanel.tsx | 100 ++++++++++++++++++ .../ehr/__tests__/ChartTabPanel.test.tsx | 35 ++++++ .../src/lib/__tests__/demoData.test.ts | 35 ++++++ apps/dashboard/src/lib/demoData.ts | 23 ++++ 4 files changed, 193 insertions(+) create mode 100644 apps/dashboard/src/components/ehr/ChartTabPanel.tsx create mode 100644 apps/dashboard/src/components/ehr/__tests__/ChartTabPanel.test.tsx diff --git a/apps/dashboard/src/components/ehr/ChartTabPanel.tsx b/apps/dashboard/src/components/ehr/ChartTabPanel.tsx new file mode 100644 index 0000000..a241e90 --- /dev/null +++ b/apps/dashboard/src/components/ehr/ChartTabPanel.tsx @@ -0,0 +1,100 @@ +import type { DEMO_CHART_DATA } from '@/lib/demoData'; + +type ChartData = typeof DEMO_CHART_DATA; + +interface ChartTabPanelProps { + activeTab: string; + chartData: ChartData; +} + +function ProblemsPanel({ problems }: { problems: ChartData['problems'] }) { + return ( +
    + {problems.map((problem) => ( +
  • + {problem.code} + {problem.description} +
  • + ))} +
+ ); +} + +function MedicationsPanel({ medications }: { medications: ChartData['medications'] }) { + return ( +
    + {medications.map((med) => ( +
  • + {med.name} + + {med.dosage} {med.frequency} + +
  • + ))} +
+ ); +} + +function AllergiesPanel({ allergies }: { allergies: ChartData['allergies'] }) { + return ( +
+ {allergies === 'NKDA' ? 'No Known Drug Allergies' : allergies} +
+ ); +} + +function ImagingPanel({ imagingHistory }: { imagingHistory: ChartData['imagingHistory'] }) { + if (imagingHistory.length === 0) { + return
No prior lumbar imaging
; + } + + return ( +
    + {imagingHistory.map((item) => ( +
  • + {item.type} + {item.date} + {item.result} +
  • + ))} +
+ ); +} + +function VitalsPanel() { + return ( +
+
BP: 128/82
+
HR: 72
+
Temp: 98.6°F
+
SpO2: 99%
+
+ ); +} + +function LabsPanel({ labResults }: { labResults: ChartData['labResults'] }) { + return ( +
    + {labResults.map((lab) => ( +
  • + {lab.name} + {lab.value} + {lab.date} +
  • + ))} +
+ ); +} + +export function ChartTabPanel({ activeTab, chartData }: ChartTabPanelProps) { + return ( +
+ {activeTab === 'problems' && } + {activeTab === 'medications' && } + {activeTab === 'allergies' && } + {activeTab === 'imaging' && } + {activeTab === 'vitals' && } + {activeTab === 'labs' && } +
+ ); +} diff --git a/apps/dashboard/src/components/ehr/__tests__/ChartTabPanel.test.tsx b/apps/dashboard/src/components/ehr/__tests__/ChartTabPanel.test.tsx new file mode 100644 index 0000000..17ef42f --- /dev/null +++ b/apps/dashboard/src/components/ehr/__tests__/ChartTabPanel.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ChartTabPanel } from '../ChartTabPanel'; +import { DEMO_CHART_DATA } from '@/lib/demoData'; + +describe('ChartTabPanel', () => { + it('ChartTabPanel_Problems_RendersICDCodes', () => { + render(); + + expect(screen.getByText('M54.5')).toBeInTheDocument(); + expect(screen.getByText('M54.41')).toBeInTheDocument(); + expect(screen.getByText('Low back pain')).toBeInTheDocument(); + expect(screen.getByText(/Lumbago with sciatica/)).toBeInTheDocument(); + }); + + it('ChartTabPanel_Meds_RendersMedicationList', () => { + render(); + + expect(screen.getByText('Ibuprofen')).toBeInTheDocument(); + expect(screen.getByText('Cyclobenzaprine')).toBeInTheDocument(); + expect(screen.getByText('Gabapentin')).toBeInTheDocument(); + }); + + it('ChartTabPanel_Allergies_RendersNKDA', () => { + render(); + + expect(screen.getByText('No Known Drug Allergies')).toBeInTheDocument(); + }); + + it('ChartTabPanel_Imaging_RendersNoHistory', () => { + render(); + + expect(screen.getByText('No prior lumbar imaging')).toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/lib/__tests__/demoData.test.ts b/apps/dashboard/src/lib/__tests__/demoData.test.ts index 5310f50..283f174 100644 --- a/apps/dashboard/src/lib/__tests__/demoData.test.ts +++ b/apps/dashboard/src/lib/__tests__/demoData.test.ts @@ -14,6 +14,7 @@ import { DEMO_PA_RESULT_SOURCES, LCD_L34220_POLICY, buildPreCheckCriteria, + DEMO_CHART_DATA, } from '../demoData'; describe('demoData', () => { @@ -141,3 +142,37 @@ describe('demoData', () => { expect(LCD_L34220_POLICY.criteria.every((c) => c.requirement.length > 0)).toBe(true); }); }); + +describe('DEMO_CHART_DATA', () => { + it('demoChartData_HasProblemList_WithICDCodes', () => { + expect(DEMO_CHART_DATA.problems).toBeDefined(); + expect(DEMO_CHART_DATA.problems.length).toBeGreaterThanOrEqual(2); + + const codes = DEMO_CHART_DATA.problems.map((p) => p.code); + expect(codes).toContain('M54.5'); + expect(codes).toContain('M54.41'); + }); + + it('demoChartData_HasMedications_WithDosages', () => { + expect(DEMO_CHART_DATA.medications).toBeDefined(); + expect(DEMO_CHART_DATA.medications.length).toBeGreaterThanOrEqual(2); + + const names = DEMO_CHART_DATA.medications.map((m) => m.name); + expect(names).toContain('Ibuprofen'); + expect(names).toContain('Cyclobenzaprine'); + + for (const med of DEMO_CHART_DATA.medications) { + expect(med.dosage).toBeTruthy(); + } + }); + + it('demoChartData_HasAllergies_NKDA', () => { + expect(DEMO_CHART_DATA.allergies).toBe('NKDA'); + }); + + it('demoChartData_HasEmptyImagingHistory', () => { + expect(DEMO_CHART_DATA.imagingHistory).toBeDefined(); + expect(Array.isArray(DEMO_CHART_DATA.imagingHistory)).toBe(true); + expect(DEMO_CHART_DATA.imagingHistory).toHaveLength(0); + }); +}); diff --git a/apps/dashboard/src/lib/demoData.ts b/apps/dashboard/src/lib/demoData.ts index 03e743d..e50c2ea 100644 --- a/apps/dashboard/src/lib/demoData.ts +++ b/apps/dashboard/src/lib/demoData.ts @@ -325,3 +325,26 @@ export const DEMO_PA_RESULT: PARequest = { }, ], }; + +/** + * Chart-level data for the EHR sidebar tabs (problems, medications, etc.). + * Used by ChartTabPanel to render clinical context during the demo. + */ +export const DEMO_CHART_DATA = { + problems: [ + { code: 'M54.5', description: 'Low back pain', category: 'Musculoskeletal' }, + { code: 'M54.41', description: 'Lumbago with sciatica, right side', category: 'Musculoskeletal' }, + { code: 'M79.3', description: 'Panniculitis, unspecified', category: 'Musculoskeletal' }, + ], + medications: [ + { name: 'Ibuprofen', dosage: '800mg', frequency: 'TID', status: 'Active' }, + { name: 'Cyclobenzaprine', dosage: '10mg', frequency: 'TID PRN', status: 'Active' }, + { name: 'Gabapentin', dosage: '300mg', frequency: 'TID', status: 'Active' }, + ], + allergies: 'NKDA' as const, + imagingHistory: [] as Array<{ date: string; type: string; result: string }>, + labResults: [ + { name: 'CBC', value: 'Within normal limits', date: '2026-02-28' }, + { name: 'CRP', value: '2.1 mg/L', date: '2026-02-28' }, + ], +}; From 5dc4dfba3fcf46fb92f2ef26f1facc116413cce0 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:29:27 -0700 Subject: [PATCH 09/21] feat(pa-dashboard-demo): task-005 -- FleetCard and FleetView components Add compact FleetCard with patient initials, status dot, CPT code, payer badge, and confidence display. FleetView provides a filterable grid with highlight support. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/fleet/FleetCard.tsx | 86 +++++++++++ .../src/components/fleet/FleetView.tsx | 33 +++++ .../fleet/__tests__/FleetCard.test.tsx | 138 ++++++++++++++++++ .../fleet/__tests__/FleetView.test.tsx | 88 +++++++++++ 4 files changed, 345 insertions(+) create mode 100644 apps/dashboard/src/components/fleet/FleetCard.tsx create mode 100644 apps/dashboard/src/components/fleet/FleetView.tsx create mode 100644 apps/dashboard/src/components/fleet/__tests__/FleetCard.test.tsx create mode 100644 apps/dashboard/src/components/fleet/__tests__/FleetView.test.tsx diff --git a/apps/dashboard/src/components/fleet/FleetCard.tsx b/apps/dashboard/src/components/fleet/FleetCard.tsx new file mode 100644 index 0000000..5fc6ce6 --- /dev/null +++ b/apps/dashboard/src/components/fleet/FleetCard.tsx @@ -0,0 +1,86 @@ +import { cn } from '@/lib/utils'; +import type { FleetPARequest, FleetStatus } from '@/lib/fleetSeedData'; + +interface FleetCardProps { + request: FleetPARequest; + highlighted?: boolean; + onSelect: (id: string) => void; +} + +const STATUS_COLORS: Record = { + processing: 'bg-blue-500', + ready: 'bg-purple-500', + submitted: 'bg-amber-500', + waiting_for_insurance: 'bg-sky-500', + approved: 'bg-green-500', + denied: 'bg-red-500', +}; + +/** Statuses where confidence has been computed */ +const ANALYZED_STATUSES: FleetStatus[] = [ + 'ready', + 'submitted', + 'waiting_for_insurance', + 'approved', + 'denied', +]; + +function getInitials(name: string): string { + return name + .split(' ') + .map((part) => part[0]) + .join('') + .toUpperCase() + .slice(0, 2); +} + +export function FleetCard({ request, highlighted = false, onSelect }: FleetCardProps) { + const initials = getInitials(request.patient.name); + const showConfidence = ANALYZED_STATUSES.includes(request.status); + + return ( +
onSelect(request.id)} + className={cn( + 'rounded-lg border border-border/50 bg-card p-2.5 cursor-pointer transition-all duration-200 hover:shadow-md', + highlighted && 'ring-2 ring-teal-400/50 shadow-lg shadow-teal-500/20', + )} + > +
+ {/* Avatar with initials */} +
+ {initials} +
+ + {/* Status dot */} + + + {/* CPT code */} + + {request.procedureCode} + +
+ +
+ {/* Payer badge */} + + {request.payer} + + + {/* Confidence */} + {showConfidence && ( + + {request.confidence}% + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/fleet/FleetView.tsx b/apps/dashboard/src/components/fleet/FleetView.tsx new file mode 100644 index 0000000..d47b295 --- /dev/null +++ b/apps/dashboard/src/components/fleet/FleetView.tsx @@ -0,0 +1,33 @@ +import type { FleetPARequest } from '@/lib/fleetSeedData'; +import { FleetCard } from './FleetCard'; + +interface FleetViewProps { + requests: FleetPARequest[]; + filter: string | null; + highlightedCaseId?: string; + onSelectCase: (id: string) => void; +} + +export function FleetView({ + requests, + filter, + highlightedCaseId, + onSelectCase, +}: FleetViewProps) { + const visible = filter + ? requests.filter((r) => r.status === filter) + : requests; + + return ( +
+ {visible.map((request) => ( + + ))} +
+ ); +} diff --git a/apps/dashboard/src/components/fleet/__tests__/FleetCard.test.tsx b/apps/dashboard/src/components/fleet/__tests__/FleetCard.test.tsx new file mode 100644 index 0000000..9e5e04a --- /dev/null +++ b/apps/dashboard/src/components/fleet/__tests__/FleetCard.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FleetCard } from '../FleetCard'; +import type { FleetPARequest } from '@/lib/fleetSeedData'; + +function makeRequest(overrides: Partial = {}): FleetPARequest { + return { + id: 'fleet-001', + patientId: '60182', + fhirPatientId: 'a-195900.E-60182', + patient: { + id: '60182', + name: 'Rebecca Sandbox', + mrn: '60182', + dob: '09/14/1990', + memberId: 'ATH60182', + payer: 'Aetna', + address: '654 Birch Road, Tacoma, WA 98402', + phone: '(253) 555-0654', + }, + procedureCode: '72148', + procedureName: 'MRI Lumbar Spine without Contrast', + diagnosis: 'Low back pain', + diagnosisCode: 'M54.5', + payer: 'Aetna', + provider: 'Dr. Sarah Chen', + providerNpi: '1234567890', + serviceDate: '2026-03-15', + placeOfService: 'Office', + clinicalSummary: 'Patient presents for MRI', + status: 'ready', + confidence: 85, + createdAt: '2026-03-10T08:00:00Z', + updatedAt: '2026-03-10T10:00:00Z', + readyAt: '2026-03-10T12:00:00Z', + submittedAt: null, + criteria: [ + { met: true, label: 'Valid ICD-10 diagnosis', reason: 'Covered' }, + ], + ...overrides, + }; +} + +describe('FleetCard', () => { + it('FleetCard_RendersPatientInitials', () => { + render( + , + ); + expect(screen.getByText('RS')).toBeInTheDocument(); + }); + + it('FleetCard_RendersStatusDot_WithCorrectColor', () => { + const { rerender } = render( + , + ); + const approvedDot = screen.getByTestId('status-dot'); + expect(approvedDot.className).toContain('green'); + + rerender( + , + ); + const deniedDot = screen.getByTestId('status-dot'); + expect(deniedDot.className).toContain('red'); + + rerender( + , + ); + const processingDot = screen.getByTestId('status-dot'); + expect(processingDot.className).toContain('blue'); + }); + + it('FleetCard_RendersProcedureCode', () => { + render( + , + ); + expect(screen.getByText('72148')).toBeInTheDocument(); + }); + + it('FleetCard_RendersPayerBadge', () => { + render( + , + ); + expect(screen.getByText('Aetna')).toBeInTheDocument(); + }); + + it('FleetCard_RendersConfidence_WhenAnalyzed', () => { + render( + , + ); + expect(screen.getByText('85%')).toBeInTheDocument(); + }); + + it('FleetCard_HighlightCase_ShowsGlow', () => { + render( + , + ); + const card = screen.getByTestId('fleet-card-fleet-001'); + expect(card.className).toContain('ring-2'); + expect(card.className).toContain('teal'); + }); + + it('FleetCard_Click_CallsOnSelect', () => { + const onSelect = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId('fleet-card-fleet-001')); + expect(onSelect).toHaveBeenCalledWith('fleet-001'); + }); +}); diff --git a/apps/dashboard/src/components/fleet/__tests__/FleetView.test.tsx b/apps/dashboard/src/components/fleet/__tests__/FleetView.test.tsx new file mode 100644 index 0000000..389dfce --- /dev/null +++ b/apps/dashboard/src/components/fleet/__tests__/FleetView.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { FleetView } from '../FleetView'; +import type { FleetPARequest } from '@/lib/fleetSeedData'; + +function makeRequests(): FleetPARequest[] { + const base = { + patientId: '60182', + fhirPatientId: 'a-195900.E-60182', + patient: { + id: '60182', + name: 'Rebecca Sandbox', + mrn: '60182', + dob: '09/14/1990', + memberId: 'ATH60182', + payer: 'Aetna', + address: '654 Birch Road, Tacoma, WA 98402', + phone: '(253) 555-0654', + }, + procedureCode: '72148', + procedureName: 'MRI Lumbar Spine without Contrast', + diagnosis: 'Low back pain', + diagnosisCode: 'M54.5', + payer: 'Aetna', + provider: 'Dr. Sarah Chen', + providerNpi: '1234567890', + serviceDate: '2026-03-15', + placeOfService: 'Office', + clinicalSummary: 'Patient presents for MRI', + confidence: 85, + createdAt: '2026-03-10T08:00:00Z', + updatedAt: '2026-03-10T10:00:00Z', + readyAt: '2026-03-10T12:00:00Z', + submittedAt: null, + criteria: [{ met: true, label: 'Valid ICD-10', reason: 'OK' }], + }; + + return [ + { ...base, id: 'fleet-001', status: 'ready' as const }, + { ...base, id: 'fleet-002', status: 'approved' as const }, + { ...base, id: 'fleet-003', status: 'processing' as const }, + { ...base, id: 'fleet-004', status: 'submitted' as const }, + ]; +} + +describe('FleetView', () => { + it('FleetView_RendersAllCases_AsFleetCards', () => { + render( + , + ); + expect(screen.getByTestId('fleet-card-fleet-001')).toBeInTheDocument(); + expect(screen.getByTestId('fleet-card-fleet-002')).toBeInTheDocument(); + expect(screen.getByTestId('fleet-card-fleet-003')).toBeInTheDocument(); + expect(screen.getByTestId('fleet-card-fleet-004')).toBeInTheDocument(); + }); + + it('FleetView_FilterByStatus_ShowsOnlyMatching', () => { + render( + , + ); + expect(screen.getByTestId('fleet-card-fleet-001')).toBeInTheDocument(); + expect(screen.queryByTestId('fleet-card-fleet-002')).not.toBeInTheDocument(); + expect(screen.queryByTestId('fleet-card-fleet-003')).not.toBeInTheDocument(); + expect(screen.queryByTestId('fleet-card-fleet-004')).not.toBeInTheDocument(); + }); + + it('FleetView_HighlightedCaseId_PassesToFleetCard', () => { + render( + , + ); + const highlightedCard = screen.getByTestId('fleet-card-fleet-002'); + expect(highlightedCard.className).toContain('ring-2'); + expect(highlightedCard.className).toContain('teal'); + }); +}); From 47edb4936d801e14d91a6af86d8eb620b4560bbe Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:30:04 -0700 Subject: [PATCH 10/21] =?UTF-8?q?feat(pa-dashboard-demo):=20task-012=20?= =?UTF-8?q?=E2=80=94=20React=20Flow=20custom=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PatientNode, EvidenceNode, CriteriaNode, DecisionNode components with shared NodeCard wrapper. Each node has typed data props, styled borders, and React Flow handles for graph connectivity. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/dashboard/package.json | 2 + .../src/components/case/CriteriaNode.tsx | 68 +++++++++ .../src/components/case/DecisionNode.tsx | 86 ++++++++++++ .../src/components/case/EvidenceNode.tsx | 50 +++++++ .../src/components/case/NodeCard.tsx | 26 ++++ .../src/components/case/PatientNode.tsx | 51 +++++++ .../case/__tests__/CustomNodes.test.tsx | 129 ++++++++++++++++++ 7 files changed, 412 insertions(+) create mode 100644 apps/dashboard/src/components/case/CriteriaNode.tsx create mode 100644 apps/dashboard/src/components/case/DecisionNode.tsx create mode 100644 apps/dashboard/src/components/case/EvidenceNode.tsx create mode 100644 apps/dashboard/src/components/case/NodeCard.tsx create mode 100644 apps/dashboard/src/components/case/PatientNode.tsx create mode 100644 apps/dashboard/src/components/case/__tests__/CustomNodes.test.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index e5116c3..a48b4b0 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -28,6 +28,7 @@ "@tanstack/react-query": "^5.80.6", "@tanstack/react-router": "1.131.35", "@tanstack/router-plugin": "1.131.35", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "graphql": "^16.9.0", @@ -35,6 +36,7 @@ "html2canvas": "^1.4.1", "jspdf": "^4.1.0", "lucide-react": "^0.513.0", + "motion": "^12.36.0", "pdf-lib": "^1.17.1", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/apps/dashboard/src/components/case/CriteriaNode.tsx b/apps/dashboard/src/components/case/CriteriaNode.tsx new file mode 100644 index 0000000..096ae43 --- /dev/null +++ b/apps/dashboard/src/components/case/CriteriaNode.tsx @@ -0,0 +1,68 @@ +import { Handle, Position } from '@xyflow/react'; +import { NodeCard } from './NodeCard'; +import type { NodeProps } from '@xyflow/react'; + +export interface CriteriaNodeData { + label: string; + status: 'met' | 'not_met' | 'indeterminate'; + reasoning?: string; +} + +const STATUS_CONFIG = { + met: { + border: 'border-green-400', + icon: '\u2713', + iconBg: 'bg-green-500 text-white', + testId: 'criteria-icon-met', + }, + not_met: { + border: 'border-red-400', + icon: '\u2717', + iconBg: 'bg-red-500 text-white', + testId: 'criteria-icon-not_met', + }, + indeterminate: { + border: 'border-amber-400', + icon: '?', + iconBg: 'bg-amber-500 text-white', + testId: 'criteria-icon-indeterminate', + }, +} as const; + +/** + * Custom React Flow node displaying a policy criterion and its status. + * Layout: status icon + criterion label. Border colored by status. + */ +export function CriteriaNode({ + data, +}: NodeProps & { data: CriteriaNodeData }) { + const config = STATUS_CONFIG[data.status]; + + return ( + + + +
+
+ {config.icon} +
+ + {data.label} + +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/case/DecisionNode.tsx b/apps/dashboard/src/components/case/DecisionNode.tsx new file mode 100644 index 0000000..a9fbe1a --- /dev/null +++ b/apps/dashboard/src/components/case/DecisionNode.tsx @@ -0,0 +1,86 @@ +import { Handle, Position } from '@xyflow/react'; +import { NodeCard } from './NodeCard'; +import type { NodeProps } from '@xyflow/react'; + +export interface DecisionNodeData { + payer: string; + policyId: string; + confidence: number; + status: string; +} + +/** + * Custom React Flow node displaying the PA decision outcome. + * Layout: payer + policy ref + confidence % with animated SVG ring + status. + */ +export function DecisionNode({ + data, +}: NodeProps & { data: DecisionNodeData }) { + const size = 64; + const strokeWidth = 5; + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const offset = circumference - (data.confidence / 100) * circumference; + + const colorClass = + data.confidence >= 80 + ? 'text-green-500' + : data.confidence >= 60 + ? 'text-amber-500' + : 'text-red-500'; + + return ( + + + +
+ {/* Animated confidence ring */} +
+ + + + +
+ + {data.confidence}% + +
+
+ +
+

{data.payer}

+

{data.policyId}

+ + {data.status} + +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/case/EvidenceNode.tsx b/apps/dashboard/src/components/case/EvidenceNode.tsx new file mode 100644 index 0000000..e91d700 --- /dev/null +++ b/apps/dashboard/src/components/case/EvidenceNode.tsx @@ -0,0 +1,50 @@ +import { Handle, Position } from '@xyflow/react'; +import { NodeCard } from './NodeCard'; +import type { NodeProps } from '@xyflow/react'; + +export interface EvidenceNodeData { + text: string; + source: string; +} + +const SOURCE_COLORS: Record = { + HPI: 'bg-blue-100 text-blue-700', + Assessment: 'bg-purple-100 text-purple-700', + Orders: 'bg-amber-100 text-amber-700', + 'Imaging History': 'bg-slate-100 text-slate-700', + 'Problem List': 'bg-green-100 text-green-700', + 'Assessment / Plan': 'bg-indigo-100 text-indigo-700', + 'CC / HPI': 'bg-sky-100 text-sky-700', + 'HPI / Orders': 'bg-cyan-100 text-cyan-700', +}; + +/** + * Custom React Flow node displaying a piece of clinical evidence. + * Layout: evidence text + small source badge (colored by type). + */ +export function EvidenceNode({ data }: NodeProps & { data: EvidenceNodeData }) { + const sourceColor = + SOURCE_COLORS[data.source] ?? 'bg-slate-100 text-slate-600'; + + return ( + + + +

{data.text}

+ +
+ + {data.source} + +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/case/NodeCard.tsx b/apps/dashboard/src/components/case/NodeCard.tsx new file mode 100644 index 0000000..2abd6e9 --- /dev/null +++ b/apps/dashboard/src/components/case/NodeCard.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/lib/utils'; +import type { ReactNode } from 'react'; + +interface NodeCardProps { + children: ReactNode; + className?: string; + borderColor?: string; +} + +/** + * Shared card wrapper for React Flow custom nodes. + * Provides consistent border, padding, rounded corners, and shadow. + */ +export function NodeCard({ children, className, borderColor }: NodeCardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/components/case/PatientNode.tsx b/apps/dashboard/src/components/case/PatientNode.tsx new file mode 100644 index 0000000..02bb80a --- /dev/null +++ b/apps/dashboard/src/components/case/PatientNode.tsx @@ -0,0 +1,51 @@ +import { Handle, Position } from '@xyflow/react'; +import { NodeCard } from './NodeCard'; +import type { NodeProps } from '@xyflow/react'; + +export interface PatientNodeData { + name: string; + dob: string; + mrn: string; + insurance: string; +} + +/** + * Custom React Flow node displaying patient demographics. + * Layout: initials avatar (teal ring) + name + DOB + MRN + insurance badge. + */ +export function PatientNode({ data }: NodeProps & { data: PatientNodeData }) { + const initials = data.name + .split(' ') + .map((w) => w[0]) + .join('') + .toUpperCase() + .slice(0, 2); + + return ( + +
+ {/* Initials avatar */} +
+ {initials} +
+
+

+ {data.name} +

+

DOB: {data.dob}

+
+
+ +
+ + MRN: {data.mrn} + + + {data.insurance} + +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/case/__tests__/CustomNodes.test.tsx b/apps/dashboard/src/components/case/__tests__/CustomNodes.test.tsx new file mode 100644 index 0000000..3ee3246 --- /dev/null +++ b/apps/dashboard/src/components/case/__tests__/CustomNodes.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +// Mock @xyflow/react Handle since we render nodes outside ReactFlow +vi.mock('@xyflow/react', () => ({ + Handle: () => null, + Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' }, +})); + +import { PatientNode } from '../PatientNode'; +import { EvidenceNode } from '../EvidenceNode'; +import { CriteriaNode } from '../CriteriaNode'; +import { DecisionNode } from '../DecisionNode'; + +// Helper to build mock node props +function mockNodeProps>(data: T) { + return { + id: 'test-node', + data, + type: 'custom', + selected: false, + isConnectable: true, + zIndex: 0, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + } as any; +} + +describe('PatientNode', () => { + const patientData = { + name: 'Rebecca Sandbox', + dob: '09/14/1990', + mrn: '60182', + insurance: 'Aetna', + }; + + it('PatientNode_RendersPatientName', () => { + render(); + expect(screen.getByText('Rebecca Sandbox')).toBeInTheDocument(); + }); + + it('PatientNode_RendersMRN', () => { + render(); + expect(screen.getByText(/60182/)).toBeInTheDocument(); + }); + + it('PatientNode_RendersInsurance', () => { + render(); + expect(screen.getByText('Aetna')).toBeInTheDocument(); + }); +}); + +describe('EvidenceNode', () => { + const evidenceData = { + text: 'Progressive numbness in left foot over past 3 weeks', + source: 'HPI', + }; + + it('EvidenceNode_RendersEvidenceText', () => { + render(); + expect( + screen.getByText('Progressive numbness in left foot over past 3 weeks'), + ).toBeInTheDocument(); + }); + + it('EvidenceNode_RendersSourceBadge', () => { + render(); + expect(screen.getByText('HPI')).toBeInTheDocument(); + }); +}); + +describe('CriteriaNode', () => { + it('CriteriaNode_MetStatus_ShowsCheckIcon', () => { + render( + , + ); + expect(screen.getByTestId('criteria-icon-met')).toBeInTheDocument(); + }); + + it('CriteriaNode_NotMetStatus_ShowsXIcon', () => { + render( + , + ); + expect(screen.getByTestId('criteria-icon-not_met')).toBeInTheDocument(); + }); + + it('CriteriaNode_IndeterminateStatus_ShowsQuestionIcon', () => { + render( + , + ); + expect( + screen.getByTestId('criteria-icon-indeterminate'), + ).toBeInTheDocument(); + }); +}); + +describe('DecisionNode', () => { + const decisionData = { + payer: 'Aetna', + policyId: 'LCD L34220', + confidence: 93, + status: 'ready', + }; + + it('DecisionNode_RendersPayerName', () => { + render(); + expect(screen.getByText('Aetna')).toBeInTheDocument(); + }); + + it('DecisionNode_RendersConfidenceScore', () => { + render(); + expect(screen.getByText('93%')).toBeInTheDocument(); + }); +}); From ecff8ca1db503cf49dbd614af46a0d1083d8cc72 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:30:31 -0700 Subject: [PATCH 11/21] feat(pa-dashboard-demo): task-006 -- CasePipeline component Add horizontal pipeline visualization with 6 stages (Order Signed through Payer Response), interactive counts, active stage highlighting, and animated SVG connectors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/fleet/CasePipeline.tsx | 110 ++++++++++++++++++ .../fleet/__tests__/CasePipeline.test.tsx | 77 ++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 apps/dashboard/src/components/fleet/CasePipeline.tsx create mode 100644 apps/dashboard/src/components/fleet/__tests__/CasePipeline.test.tsx diff --git a/apps/dashboard/src/components/fleet/CasePipeline.tsx b/apps/dashboard/src/components/fleet/CasePipeline.tsx new file mode 100644 index 0000000..317f2d9 --- /dev/null +++ b/apps/dashboard/src/components/fleet/CasePipeline.tsx @@ -0,0 +1,110 @@ +import { cn } from '@/lib/utils'; + +interface CasePipelineProps { + stageCounts: Record; + activeStage: string | null; + onFilter: (stage: string) => void; +} + +interface StageConfig { + key: string; + label: string; +} + +const STAGES: StageConfig[] = [ + { key: 'order_signed', label: 'Order Signed' }, + { key: 'pa_detected', label: 'PA Detected' }, + { key: 'processing', label: 'Processing' }, + { key: 'ready', label: 'Ready' }, + { key: 'submitted', label: 'Submitted' }, + { key: 'payer_response', label: 'Payer Response' }, +]; + +export function CasePipeline({ stageCounts, activeStage, onFilter }: CasePipelineProps) { + return ( +
+ {/* SVG connecting lines */} + + + + + {STAGES.slice(0, -1).map((_, i) => { + const x1 = `${((i + 0.5) / STAGES.length) * 100 + 3}%`; + const x2 = `${((i + 1.5) / STAGES.length) * 100 - 3}%`; + return ( + + + + + ); + })} + + + {STAGES.map((stage) => { + const count = stageCounts[stage.key] ?? 0; + const isActive = activeStage === stage.key; + + return ( + + ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/fleet/__tests__/CasePipeline.test.tsx b/apps/dashboard/src/components/fleet/__tests__/CasePipeline.test.tsx new file mode 100644 index 0000000..de65fce --- /dev/null +++ b/apps/dashboard/src/components/fleet/__tests__/CasePipeline.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CasePipeline } from '../CasePipeline'; + +const STAGE_LABELS = [ + 'Order Signed', + 'PA Detected', + 'Processing', + 'Ready', + 'Submitted', + 'Payer Response', +]; + +const mockStageCounts: Record = { + order_signed: 48, + pa_detected: 42, + processing: 6, + ready: 8, + submitted: 15, + payer_response: 19, +}; + +describe('CasePipeline', () => { + it('CasePipeline_RendersSixStages', () => { + render( + , + ); + for (const label of STAGE_LABELS) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it('CasePipeline_ShowsCountPerStage', () => { + render( + , + ); + expect(screen.getByTestId('stage-count-order_signed')).toHaveTextContent('48'); + expect(screen.getByTestId('stage-count-pa_detected')).toHaveTextContent('42'); + expect(screen.getByTestId('stage-count-processing')).toHaveTextContent('6'); + expect(screen.getByTestId('stage-count-ready')).toHaveTextContent('8'); + expect(screen.getByTestId('stage-count-submitted')).toHaveTextContent('15'); + expect(screen.getByTestId('stage-count-payer_response')).toHaveTextContent('19'); + }); + + it('CasePipeline_ClickStage_CallsOnFilter', () => { + const onFilter = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('Processing')); + expect(onFilter).toHaveBeenCalledWith('processing'); + }); + + it('CasePipeline_ActiveStage_HasHighlight', () => { + render( + , + ); + const activeStage = screen.getByTestId('pipeline-stage-processing'); + expect(activeStage.className).toMatch(/teal|ring/); + }); +}); From addc0eee5964204162b8d5cce955ca454b735ee2 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 15 Mar 2026 02:30:54 -0700 Subject: [PATCH 12/21] feat(pa-dashboard-demo): task-009 -- enhanced EncounterSidebar with chart tabs Add chart tab browser (Problems, Meds, Allergies, Vitals, Imaging, Labs) to the sidebar when chartData is provided. Enhanced mode shows extended encounter stages (Intake through Sign) and optional PA detection stages. Legacy mode (no chartData) preserves all existing behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/ehr/EncounterSidebar.tsx | 109 +++++++++++++++++- .../ehr/__tests__/EncounterSidebar.test.tsx | 57 ++++++++- 2 files changed, 161 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/components/ehr/EncounterSidebar.tsx b/apps/dashboard/src/components/ehr/EncounterSidebar.tsx index 294d023..e6f8d98 100644 --- a/apps/dashboard/src/components/ehr/EncounterSidebar.tsx +++ b/apps/dashboard/src/components/ehr/EncounterSidebar.tsx @@ -1,16 +1,36 @@ +import { useState } from 'react'; import type { EhrDemoState } from './useEhrDemoFlow'; +import type { DEMO_CHART_DATA } from '@/lib/demoData'; +import { ChartTabPanel } from './ChartTabPanel'; const ENCOUNTER_STAGES = ['Review', 'HPI', 'ROS', 'PE', 'A&P'] as const; +const ENHANCED_ENCOUNTER_STAGES = ['Intake', 'HPI', 'ROS', 'PE', 'A&P', 'Orders', 'Sign'] as const; + const PA_STAGES = ['Analyzing', 'Review', 'Submit', 'Complete'] as const; -export type StageName = typeof ENCOUNTER_STAGES[number]; +const ENHANCED_PA_STAGES = ['PA Review', 'PA Submit'] as const; + +export type StageName = typeof ENCOUNTER_STAGES[number] | typeof ENHANCED_ENCOUNTER_STAGES[number]; + +type ChartData = typeof DEMO_CHART_DATA; + +const CHART_TABS = [ + { id: 'problems', label: 'Problems' }, + { id: 'medications', label: 'Meds' }, + { id: 'allergies', label: 'Allergies' }, + { id: 'vitals', label: 'Vitals' }, + { id: 'imaging', label: 'Imaging' }, + { id: 'labs', label: 'Labs' }, +] as const; interface EncounterSidebarProps { activeStage?: StageName; signed?: boolean; flowState?: EhrDemoState; preCheckCount?: { met: number; total: number }; + chartData?: ChartData; + paDetected?: boolean; } type StageState = 'completed' | 'active' | 'pending'; @@ -113,30 +133,111 @@ function StageList({ ); } +function ChartTabsGrid({ + chartData, + activeTab, + onTabClick, +}: { + chartData: ChartData; + activeTab: string | null; + onTabClick: (tabId: string) => void; +}) { + return ( +
+

+ Chart +

+
+ {CHART_TABS.map((tab) => ( + + ))} +
+ {activeTab && ( +
+ +
+ )} +
+ ); +} + export function EncounterSidebar({ activeStage = 'A&P', signed = false, flowState = 'idle', preCheckCount, + chartData, + paDetected, }: EncounterSidebarProps) { + const [activeChartTab, setActiveChartTab] = useState(null); + + const isEnhanced = !!chartData; + const stages = isEnhanced ? ENHANCED_ENCOUNTER_STAGES : ENCOUNTER_STAGES; + // When signed, all encounter stages are completed (activeIndex past last stage) - const activeIndex = signed ? ENCOUNTER_STAGES.length : ENCOUNTER_STAGES.indexOf(activeStage); + const activeIndex = signed ? stages.length : (stages as readonly string[]).indexOf(activeStage); const isFlagged = flowState === 'flagged'; const showPAStages = flowState !== 'idle' && flowState !== 'error' && flowState !== 'flagged'; + + // In legacy mode, use PA_STAGES; in enhanced mode, use ENHANCED_PA_STAGES + const paStages = isEnhanced ? ENHANCED_PA_STAGES : PA_STAGES; const paActiveIndex = getPAActiveIndex(flowState); + const handleTabClick = (tabId: string) => { + setActiveChartTab((prev) => (prev === tabId ? null : tabId)); + }; + return (