diff --git a/wc-claims-portal/.azure/deploy.yml b/wc-claims-portal/.azure/deploy.yml new file mode 100644 index 0000000..2118d65 --- /dev/null +++ b/wc-claims-portal/.azure/deploy.yml @@ -0,0 +1,67 @@ +name: Deploy WC Claims Portal to Azure Static Web Apps + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - main + +jobs: + build_and_deploy: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Build and Deploy + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: wc-claims-portal/package-lock.json + + - name: Install dependencies + working-directory: wc-claims-portal + run: npm ci + + - name: Build Next.js app + working-directory: wc-claims-portal + env: + NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }} + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + AZURE_AD_CLIENT_ID: ${{ secrets.AZURE_AD_CLIENT_ID }} + AZURE_AD_CLIENT_SECRET: ${{ secrets.AZURE_AD_CLIENT_SECRET }} + AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} + AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_DEPLOYMENT_NAME }} + NEXT_PUBLIC_APP_NAME: "WC Claims Agent Portal" + NEXT_PUBLIC_ORG_NAME: "AdaptCloud" + run: npm run build + + - name: Deploy to Azure Static Web Apps + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} + repo_token: ${{ secrets.GITHUB_TOKEN }} + action: "upload" + app_location: "wc-claims-portal" + output_location: ".next/standalone" + skip_app_build: true + + close_pull_request: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + name: Close Pull Request + + steps: + - name: Close Pull Request + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} + action: "close" diff --git a/wc-claims-portal/.env.example b/wc-claims-portal/.env.example new file mode 100644 index 0000000..793a540 --- /dev/null +++ b/wc-claims-portal/.env.example @@ -0,0 +1,23 @@ +# Azure AD / NextAuth +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET= + +AZURE_AD_CLIENT_ID= +AZURE_AD_CLIENT_SECRET= +AZURE_AD_TENANT_ID= + +# Azure OpenAI +AZURE_OPENAI_ENDPOINT=https://.openai.azure.com +AZURE_OPENAI_API_KEY= +AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o + +# Claims data source +# demo — seeded in-memory data, no external dependencies (default) +# production — live CMS API; requires CLAIMS_API_BASE_URL and CLAIMS_API_KEY +CLAIMS_DATA_MODE=demo +CLAIMS_API_BASE_URL=https:///v1 +CLAIMS_API_KEY= + +# App +NEXT_PUBLIC_APP_NAME="WC Claims Agent Portal" +NEXT_PUBLIC_ORG_NAME="AdaptCloud" diff --git a/wc-claims-portal/.gitignore b/wc-claims-portal/.gitignore new file mode 100644 index 0000000..d2aeb22 --- /dev/null +++ b/wc-claims-portal/.gitignore @@ -0,0 +1,30 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Next.js build output +.next/ +out/ + +# Production +build/ + +# Env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Vercel / Azure +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts diff --git a/wc-claims-portal/README.md b/wc-claims-portal/README.md new file mode 100644 index 0000000..310f63b --- /dev/null +++ b/wc-claims-portal/README.md @@ -0,0 +1,596 @@ +# WC Claims Agent Portal + +An AI-assisted Workers' Compensation claims management portal. Adjusters sign in with their Microsoft work account, open a claim, and interact with a streaming AI assistant that is pre-loaded with WC-domain knowledge and jurisdiction-specific context for the active claim. + +Built with Next.js 14 App Router · Azure AD (Entra ID) · Azure OpenAI · Azure Static Web Apps. + +--- + +## Table of Contents + +- [Architecture](#architecture) + - [Demo Mode](#demo-mode) + - [Production Mode](#production-mode) + - [Data Mode Decision](#data-mode-decision) +- [Prerequisites](#prerequisites) +- [Deployment: Demo Mode](#deployment-demo-mode) +- [Deployment: Production Mode](#deployment-production-mode) +- [Azure Infrastructure (Bicep)](#azure-infrastructure-bicep) +- [Environment Variables Reference](#environment-variables-reference) +- [MFA Enforcement](#mfa-enforcement) +- [GitHub Actions CI/CD](#github-actions-cicd) +- [GitHub Copilot Compatibility](#github-copilot-compatibility) +- [Project Structure](#project-structure) +- [Key Routes](#key-routes) +- [Security Notes](#security-notes) + +--- + +## Architecture + +### Demo Mode + +Used for evaluation, internal showcasing, and development. No external claims system required. Seed data covers 10 realistic WC claims across the 10 largest US WC jurisdictions (CA, TX, FL, NY, IL, GA, WA, PA, OH, CO). + +``` +Browser + │ + │ HTTPS / Azure AD session + ▼ +┌─────────────────────────────────────────────────────┐ +│ Azure Static Web Apps │ +│ │ +│ ┌─────────────────┐ ┌───────────────────────┐ │ +│ │ React UI │ │ Next.js API routes │ │ +│ │ Dashboard │ │ │ │ +│ │ Claims table │ │ /api/auth ──────────┼───┼──► Entra ID (Azure AD) +│ │ Claim detail │ │ /api/claims ─────────┼───┼──► DemoClaimsAdapter +│ │ AI chat panel │ │ /api/chat ──────────┼───┼──► Azure OpenAI (GPT-4o) +│ └─────────────────┘ └───────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + │ + DemoClaimsAdapter + (in-memory seed data — + 10 claims, no DB needed) +``` + +### Production Mode + +Connects to your existing claims management system (ClaimCenter, Majesco, Duck Creek, or custom API) via the `ProductionClaimsAdapter`. All other components — auth, AI, hosting — remain identical. + +``` +Browser + │ + │ HTTPS / Azure AD session (MFA enforced) + ▼ +┌─────────────────────────────────────────────────────┐ +│ Azure Static Web Apps │ +│ │ +│ ┌─────────────────┐ ┌───────────────────────┐ │ +│ │ React UI │ │ Next.js API routes │ │ +│ │ Dashboard │ │ │ │ +│ │ Claims table │ │ /api/auth ──────────┼───┼──► Entra ID (Azure AD) +│ │ Claim detail │ │ /api/claims ─────────┼───┼──► ProductionClaimsAdapter +│ │ AI chat panel │ │ /api/chat ──────────┼───┼──► Azure OpenAI (GPT-4o) +│ └─────────────────┘ └───────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + │ + ProductionClaimsAdapter + │ + CLAIMS_API_BASE_URL + (your CMS REST API) + │ + ┌───────────────────────┐ + │ Claims Management │ + │ System │ + │ (ClaimCenter / │ + │ Majesco / custom) │ + └───────────────────────┘ +``` + +### Data Mode Decision + +The mode is set by a single environment variable evaluated at startup. Nothing else in the application changes. + +``` +CLAIMS_DATA_MODE=demo → DemoClaimsAdapter (seeded in-memory, zero dependencies) +CLAIMS_DATA_MODE=production → ProductionClaimsAdapter (live CMS API) +``` + +The adapter pattern means the entire UI, AI integration, and auth layer are identical in both modes. Switching from Demo to Production is a configuration change, not a code change. + +**ClaimsAdapter interface** — the contract both adapters must satisfy: + +```typescript +interface ClaimsAdapter { + listClaims(): Promise + getClaim(id: string): Promise + getDashboardMetrics(): Promise + getRecentActivity(limit?: number): Promise +} +``` + +--- + +## Prerequisites + +- Node.js 20+ +- Azure subscription with: + - Entra ID: Application Administrator or Global Administrator role + - Azure OpenAI access approved ([request here](https://aka.ms/oai/access) — takes 1–2 business days) + - Ability to create Static Web App resources (Standard tier) +- GitHub repository connected to your Azure subscription + +--- + +## Deployment: Demo Mode + +Demo mode requires no claims management system. Complete these steps in order. + +### Step 1 — Entra ID App Registration + +```bash +# Create the registration +az ad app create \ + --display-name "wc-claims-portal-dev" \ + --sign-in-audience AzureADMyOrg \ + --web-redirect-uris "http://localhost:3000/api/auth/callback/azure-ad" + +# Get Client ID and Tenant ID +az ad app list --display-name "wc-claims-portal-dev" \ + --query "[0].{clientId:appId}" -o tsv + +az account show --query tenantId -o tsv + +# Create a client secret (copy the 'value' — shown once only) +APP_ID=$(az ad app list --display-name "wc-claims-portal-dev" --query "[0].appId" -o tsv) +az ad app credential reset --id $APP_ID --years 2 +``` + +Save: `AZURE_AD_CLIENT_ID`, `AZURE_AD_CLIENT_SECRET`, `AZURE_AD_TENANT_ID` + +### Step 2 — Deploy Azure infrastructure + +```bash +cd wc-claims-portal + +# Review region and prefix in infra/main.bicepparam, then: +./infra/deploy.sh +``` + +The script prints all four output values on completion. Copy them. + +### Step 3 — Retrieve the OpenAI API key + +```bash +az cognitiveservices account keys list \ + --resource-group rg-adaptcloud-wc-claims-dev \ + --name oai-adaptcloud-wc-claims-dev \ + --query key1 -o tsv +``` + +Save as `AZURE_OPENAI_API_KEY`. + +### Step 4 — Generate NextAuth secret + +```bash +openssl rand -base64 32 +``` + +Save as `NEXTAUTH_SECRET`. + +### Step 5 — Set GitHub Actions secrets + +**Repo → Settings → Secrets and variables → Actions → New repository secret** + +| Secret | Value | +|---|---| +| `NEXTAUTH_URL` | `https://` from deploy output | +| `NEXTAUTH_SECRET` | from Step 4 | +| `AZURE_AD_CLIENT_ID` | from Step 1 | +| `AZURE_AD_CLIENT_SECRET` | from Step 1 | +| `AZURE_AD_TENANT_ID` | from Step 1 | +| `AZURE_OPENAI_ENDPOINT` | from deploy output | +| `AZURE_OPENAI_API_KEY` | from Step 3 | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | from deploy output (`gpt-4o`) | +| `AZURE_STATIC_WEB_APPS_API_TOKEN` | from deploy output | +| `CLAIMS_DATA_MODE` | `demo` | + +### Step 6 — Set SWA Application Settings (runtime) + +```bash +az staticwebapp appsettings set \ + --name swa-adaptcloud-wc-claims-dev \ + --resource-group rg-adaptcloud-wc-claims-dev \ + --setting-names \ + NEXTAUTH_URL="https://" \ + NEXTAUTH_SECRET="" \ + AZURE_AD_CLIENT_ID="" \ + AZURE_AD_CLIENT_SECRET="" \ + AZURE_AD_TENANT_ID="" \ + AZURE_OPENAI_ENDPOINT="" \ + AZURE_OPENAI_API_KEY="" \ + AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o" \ + CLAIMS_DATA_MODE="demo" +``` + +### Step 7 — Add the production redirect URI + +```bash +APP_ID=$(az ad app list --display-name "wc-claims-portal-dev" --query "[0].appId" -o tsv) +az ad app update --id $APP_ID \ + --web-redirect-uris \ + "http://localhost:3000/api/auth/callback/azure-ad" \ + "https:///api/auth/callback/azure-ad" +``` + +### Step 8 — Trigger first deployment + +Push to `main`. The GitHub Actions workflow builds and deploys automatically. Monitor in the **Actions** tab. + +### Step 9 — Configure MFA Conditional Access policy + +See [MFA Enforcement](#mfa-enforcement) below. Do this before sharing the URL with any users. + +--- + +## Deployment: Production Mode + +Complete all Demo Mode steps first, replacing `CLAIMS_DATA_MODE=demo` with `CLAIMS_DATA_MODE=production` everywhere, then complete the additional steps below. + +### Additional Step A — Implement the ProductionClaimsAdapter + +Open `src/lib/claims/adapters/production.ts`. Replace each `throw new Error(...)` stub with a real call to your CMS API. The `fetch` helper and constructor are already wired to `CLAIMS_API_BASE_URL` and `CLAIMS_API_KEY`. + +The `ClaimsAdapter` interface in `src/lib/claims/adapter.ts` defines the exact contract. Your CMS responses must map to the types in `src/lib/claims/types.ts`. Key fields the rest of the application depends on: + +| Field | Type | Used by | +|---|---|---| +| `jurisdiction` | 2-letter state code | AI system prompt, VCK form selection | +| `type` | `Medical-Only \| Lost-Time \| PPD \| PTD` | Dashboard, filters | +| `icd10Codes` | `string[]` | AI context, future form pre-fill | +| `claimant`, `employer` | Full objects with address | Form pre-population | +| `status` | `active \| pending \| rtw \| escalated \| closed` | Dashboard metrics, badges | + +### Additional Step B — Add CMS credentials to SWA and GitHub secrets + +```bash +az staticwebapp appsettings set \ + --name swa-adaptcloud-wc-claims-dev \ + --resource-group rg-adaptcloud-wc-claims-dev \ + --setting-names \ + CLAIMS_DATA_MODE="production" \ + CLAIMS_API_BASE_URL="https:///v1" \ + CLAIMS_API_KEY="" +``` + +Add the same three values as GitHub secrets (`CLAIMS_DATA_MODE`, `CLAIMS_API_BASE_URL`, `CLAIMS_API_KEY`). + +### Additional Step C — Verify network connectivity + +If your CMS API is behind a private network or firewall, Azure Static Web Apps will need either: +- An **allowlisted outbound IP range** on the CMS side (SWA uses shared Azure egress IPs — get the current list from Azure documentation), or +- A **VNet integration** (requires SWA Dedicated tier, ~$120/month) + +For internal CMS APIs not accessible from the internet, the VNet integration path is the right one. + +--- + +## Azure Infrastructure (Bicep) + +The `infra/` directory contains a subscription-scoped Bicep deployment that creates all required Azure resources. + +``` +infra/ +├── main.bicep # Creates resource group, calls modules +├── main.bicepparam # Dev environment parameter values +├── modules/ +│ ├── openai.bicep # Azure OpenAI account + GPT-4o deployment (S0, Standard) +│ └── staticwebapp.bicep # Static Web App (Standard tier) +└── deploy.sh # Wrapper: validates, deploys, prints all output values +``` + +**To deploy:** +```bash +./infra/deploy.sh +``` + +**To modify parameters** (region, model, capacity): +```bash +# Edit before deploying +cat infra/main.bicepparam +``` + +**Outputs printed by deploy.sh:** + +| Output | GitHub Secret / SWA Setting | +|---|---| +| `openAiEndpoint` | `AZURE_OPENAI_ENDPOINT` | +| `openAiDeploymentName` | `AZURE_OPENAI_DEPLOYMENT_NAME` | +| `swaHostname` | `NEXTAUTH_URL` (prefix with `https://`) | +| `swaDeploymentToken` | `AZURE_STATIC_WEB_APPS_API_TOKEN` | + +> The OpenAI API key is not in the Bicep outputs for security. Retrieve it separately with `az cognitiveservices account keys list`. + +--- + +## Environment Variables Reference + +### Required in all modes + +Set in `.env.local` for local dev. Set as **SWA Application Settings** for runtime and as **GitHub secrets** for build time. + +| Variable | Where to find it | Required | +|---|---|---| +| `NEXTAUTH_URL` | `http://localhost:3000` locally; `https://` in prod | Yes | +| `NEXTAUTH_SECRET` | `openssl rand -base64 32` | Yes | +| `AZURE_AD_CLIENT_ID` | Entra ID → App registration → Overview | Yes | +| `AZURE_AD_CLIENT_SECRET` | Entra ID → App registration → Certificates & secrets | Yes | +| `AZURE_AD_TENANT_ID` | Entra ID → App registration → Overview | Yes | +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI → Keys and Endpoint | Yes | +| `AZURE_OPENAI_API_KEY` | Azure OpenAI → Keys and Endpoint | Yes | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI → Model deployments | Yes | +| `CLAIMS_DATA_MODE` | `demo` or `production` | Yes | + +### Production mode only + +| Variable | Description | Required | +|---|---|---| +| `CLAIMS_API_BASE_URL` | Base URL of your CMS REST API | Yes (production) | +| `CLAIMS_API_KEY` | API key or service account token for CMS | Yes (production) | + +### Optional display + +| Variable | Description | +|---|---| +| `NEXT_PUBLIC_APP_NAME` | App title shown in the browser tab and header | +| `NEXT_PUBLIC_ORG_NAME` | Organization name shown in the sidebar | + +> `NEXT_PUBLIC_` variables are embedded at build time and sent to the browser. Never put secrets in them. + +--- + +## MFA Enforcement + +MFA is enforced at three independent layers. All three must fail simultaneously for a session without MFA to reach the application — in practice this is not possible once the Conditional Access policy is active. + +| Layer | Where | What it does | +|---|---|---| +| Conditional Access policy | Entra ID portal | Blocks the OAuth flow entirely if MFA is not completed. Primary enforcement. | +| `acr_values: "mfa"` | `src/lib/auth.ts` | Application explicitly requests MFA step-up in the authorization request, even if the user has an existing non-MFA session cookie. | +| `amr` claim check | `src/lib/auth.ts` + `src/middleware.ts` | JWT callback reads Azure AD's Authentication Methods References claim and sets `mfaVerified: boolean`. Middleware rejects any session where `mfaVerified !== true` before the request reaches any page or API route. | + +### Conditional Access policy setup + +1. **portal.azure.com → Entra ID → Security → Conditional Access → New policy** +2. Name: `CA-WC-Claims-Portal-Require-MFA` +3. **Users**: your claims adjusters group (exclude break-glass accounts) +4. **Target resources → Cloud apps**: select `wc-claims-portal-dev` +5. **Grant → Require multifactor authentication** +6. Set to **Report-only** for 24 hours, verify in Sign-in logs, then set to **On** + +--- + +## RBAC — Role-Based Access Control + +Roles are assigned in **Entra ID → App registration → App roles**, then granted to users or groups via **Enterprise Applications → Assign users and groups**. + +### Defined roles + +| Role value | Who gets it | What it unlocks | +|---|---|---| +| `Claims.Adjuster` | Front-line adjusters | Full portal access including AI chat | +| `Claims.Supervisor` | Supervisors, managers | Full portal access including AI chat | +| `Claims.ReadOnly` | Auditors, compliance | Portal and claim views; AI chat blocked | + +### Where enforcement happens + +- **`/api/chat`** — requires `Claims.Adjuster` or `Claims.Supervisor`. Returns `403 Forbidden` with a clear message for `Claims.ReadOnly` users and anyone with no role assigned. +- All other routes are accessible to any authenticated user with a valid MFA session. Role-gating on specific claim actions (reserve updates, status changes) is the next enforcement layer. + +### Adding a role in Entra ID + +1. **App registration → App roles → Create app role** + - Display name: `Claims Adjuster` + - Allowed member types: Users/Groups + - Value: `Claims.Adjuster` + - Description: Front-line WC claims adjuster — can view claims and use the AI assistant +2. **Enterprise Applications → your app → Users and groups → Add user/group** + - Select the user or group, assign the role + +Roles appear in the `id_token` claims on the user's next sign-in and flow through to `session.user.roles` automatically. + +--- + +## Audit Logging + +Every significant action is written as structured JSON to stdout. In Azure Static Web Apps, stdout flows to **Azure Monitor / Log Analytics** automatically — no additional SDK required to start. + +### What is logged + +| Event | When | +|---|---| +| `claims.list` | Any adjuster views the claims list | +| `claim.view` | Any adjuster opens a specific claim | +| `chat.request` | An AI message is sent (includes claim ID, jurisdiction, message count) | +| `chat.rate_limited` | A user hits the 20 req/min limit | +| `chat.error` | Azure OpenAI returns an error | + +### Log format + +```json +{ + "audit": true, + "type": "chat.request", + "userId": "", + "userEmail": "adjuster@yourorg.com", + "userName": "T. Brown", + "claimId": "WC-2024-0891", + "jurisdiction": "CA", + "metadata": { "messageCount": 4 }, + "timestamp": "2024-10-22T14:35:00.000Z" +} +``` + +### Querying in Log Analytics + +Once connected to a Log Analytics workspace, query audit events with: + +```kusto +AppTraces +| where Properties.audit == "true" +| project + Timestamp = todatetime(Properties.timestamp), + Type = tostring(Properties.type), + UserId = tostring(Properties.userId), + UserEmail = tostring(Properties.userEmail), + ClaimId = tostring(Properties.claimId) +| order by Timestamp desc +``` + +### Upgrading to Application Insights custom events + +When you're ready for structured telemetry, dashboards, and alerting: + +1. `npm install applicationinsights` +2. Add `APPLICATIONINSIGHTS_CONNECTION_STRING` to SWA Application Settings +3. In `src/lib/audit.ts`, add `client.trackEvent()` alongside the `console.log` — no callers change + +--- + +## GitHub Actions CI/CD + +The workflow at `.azure/deploy.yml` triggers on every push to `main` and on pull requests. + +### What it does + +1. Checks out the repo +2. Installs Node 20, runs `npm ci` in `wc-claims-portal/` +3. Runs `next build` with all secrets injected as env vars +4. Uploads `.next/standalone` to Azure Static Web Apps +5. On PR close, tears down the preview environment automatically + +### GitHub secrets required + +All variables in the [Required in all modes](#required-in-all-modes) table plus `AZURE_STATIC_WEB_APPS_API_TOKEN`. For production deployments, also add the [Production mode only](#production-mode-only) variables. + +> **Why both GitHub secrets and SWA Application Settings?** GitHub secrets are available only during the build step (compile-time). SWA Application Settings are injected at runtime into the Next.js server process. Non-`NEXT_PUBLIC_` variables must be in both places. + +--- + +## GitHub Copilot Compatibility + +| Copilot product | Compatible | Notes | +|---|---|---| +| **GitHub Copilot** (VS Code / JetBrains) | Yes | TypeScript interfaces give Copilot strong context. The `ClaimsAdapter` interface is particularly useful — Copilot can see the contract and suggest correct `ProductionClaimsAdapter` implementations when wiring a CMS. | +| **GitHub Copilot for Azure** (`@azure` extension) | Yes | The Bicep files in `infra/` are directly compatible. Ask it to modify resources, add modules, or explain the deployment. | +| **Copilot Autofix** (security scanning in PRs) | Yes | No conflicts with the current setup. Will flag issues in workflow files and source code automatically. | + +**One caveat**: `.azure/deploy.yml` builds the app manually rather than using the SWA integrated build service (`skip_app_build: true`). Copilot for Azure sometimes suggests switching to the managed build flow. Do not remove `skip_app_build: true` — the Next.js standalone output must be built with secrets available, which the SWA build service cannot access. + +--- + +## Project Structure + +``` +wc-claims-portal/ +├── infra/ +│ ├── main.bicep # Subscription-scoped entry point +│ ├── main.bicepparam # Dev environment parameters +│ ├── modules/ +│ │ ├── openai.bicep # Azure OpenAI + model deployment +│ │ └── staticwebapp.bicep # SWA (Standard tier) +│ └── deploy.sh # Deployment wrapper script +│ +├── src/ +│ ├── app/ +│ │ ├── api/ +│ │ │ ├── auth/[...nextauth]/ # NextAuth OAuth handler +│ │ │ ├── claims/ # GET /api/claims — list +│ │ │ ├── claims/[id]/ # GET /api/claims/:id — single claim +│ │ │ └── chat/ # POST /api/chat — streaming SSE +│ │ ├── auth/signin/ # Azure AD sign-in page +│ │ ├── auth/error/ # Auth error page +│ │ ├── claims/ # Claims list page +│ │ ├── claims/[id]/ # Per-claim split view (detail + AI chat) +│ │ ├── dashboard/ # KPI stats + activity feed +│ │ └── layout.tsx / page.tsx +│ │ +│ ├── components/ +│ │ ├── chat/ChatPanel.tsx # Streaming chat UI, markdown rendering +│ │ ├── claims/ +│ │ │ ├── ClaimDetail.tsx # Claim data panel (pure display, typed props) +│ │ │ ├── ClaimsTable.tsx # Claims list table (pure display, typed props) +│ │ │ ├── DashboardStats.tsx # KPI cards (pure display, typed props) +│ │ │ └── RecentActivity.tsx # Activity feed (pure display, typed props) +│ │ └── ui/ +│ │ ├── AppShell.tsx # Sidebar nav + mobile layout +│ │ └── AuthProvider.tsx # NextAuth SessionProvider wrapper +│ │ +│ ├── lib/ +│ │ ├── auth.ts # NextAuth config, MFA enforcement +│ │ ├── azure-openai.ts # OpenAIClient + WC domain system prompt +│ │ └── claims/ +│ │ ├── types.ts # Claim, ClaimSummary, DashboardMetrics, etc. +│ │ ├── adapter.ts # ClaimsAdapter interface (the contract) +│ │ ├── index.ts # Singleton factory — reads CLAIMS_DATA_MODE +│ │ └── adapters/ +│ │ ├── demo.ts # 10-claim seed dataset, in-memory +│ │ └── production.ts # CMS API stub — implement to go live +│ │ +│ ├── middleware.ts # Route protection + mfaVerified enforcement +│ └── types/ +│ ├── chat.ts # Message, Role types +│ └── next-auth.d.ts # Session augmentation (roles, oid, mfaVerified) +│ +├── staticwebapp.config.json # SWA routing rules + security headers +├── .azure/deploy.yml # GitHub Actions CI/CD workflow +├── .env.example # Environment variable template +├── next.config.ts +├── tailwind.config.ts +└── tsconfig.json +``` + +--- + +## Key Routes + +| Route | Auth + MFA | Description | +|---|---|---| +| `/` | Yes | Redirects to `/dashboard` | +| `/dashboard` | Yes | KPI metrics + recent claim activity | +| `/claims` | Yes | Full claims list with jurisdiction column | +| `/claims/[id]` | Yes | Claim detail panel + streaming AI chat | +| `/auth/signin` | No | Azure AD sign-in (Microsoft button) | +| `/auth/error` | No | Auth error display | +| `/api/auth/[...nextauth]` | No | NextAuth OAuth callback handler | +| `/api/claims` | Yes | `GET` — returns `ClaimSummary[]` | +| `/api/claims/:id` | Yes | `GET` — returns full `Claim` or 404 | +| `/api/chat` | Yes | `POST` — streaming SSE, 20 req/min per user | + +### `/api/chat` request body + +```json +{ + "messages": [{ "role": "user", "content": "..." }], + "claimId": "WC-2024-0891", + "jurisdiction": "CA" +} +``` + +`jurisdiction` is automatically injected into the AI system prompt so responses cite the correct state statutes, deadlines, and form requirements. + +--- + +## Security Notes + +- **MFA**: Three-layer enforcement — Conditional Access policy, `acr_values` in auth request, `amr` claim verified server-side. See [MFA Enforcement](#mfa-enforcement). +- **RBAC**: `/api/chat` requires `Claims.Adjuster` or `Claims.Supervisor` role. Read-only users and unenrolled accounts receive `403`. See [RBAC](#rbac--role-based-access-control). +- **Audit logging**: All claim views and AI interactions are logged as structured JSON. See [Audit Logging](#audit-logging). +- **Route protection**: `src/middleware.ts` blocks all routes except `/auth/*` and `/api/auth/*` for sessions without a verified MFA claim. +- **API protection**: All API routes independently verify `getServerSession` — they do not trust middleware alone. +- **Rate limiting**: `/api/chat` enforces 20 requests per minute per user (by Azure AD OID) using an in-memory sliding window. Prevents unbounded Azure OpenAI spend. +- **Security headers**: `staticwebapp.config.json` sets `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and Content Security Policy on all responses. +- **Secrets**: Nothing is committed to the repo. Credentials flow through GitHub Actions secrets (build time) and SWA Application Settings (runtime). +- **Session lifetime**: JWT sessions expire after 8 hours (one work day). +- **AI disclaimer**: The chat UI shows a notice that AI responses must be verified against jurisdiction statutes and clinical guidelines. The AI has no write access to any claims system. diff --git a/wc-claims-portal/infra/deploy.sh b/wc-claims-portal/infra/deploy.sh new file mode 100755 index 0000000..0144ad4 --- /dev/null +++ b/wc-claims-portal/infra/deploy.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Usage: ./infra/deploy.sh +# Deploys the WC Claims Portal infrastructure to a dev landing zone. +# Requires: az CLI 2.50+, bicep CLI 0.24+, jq + +set -euo pipefail + +SUBSCRIPTION_ID="${1:?Usage: $0 }" +DEPLOYMENT_NAME="wc-claims-portal-$(date +%Y%m%d%H%M%S)" + +echo "==> Setting active subscription: $SUBSCRIPTION_ID" +az account set --subscription "$SUBSCRIPTION_ID" + +echo "==> Validating Bicep template..." +az deployment sub validate \ + --location eastus2 \ + --template-file "$(dirname "$0")/main.bicep" \ + --parameters "$(dirname "$0")/main.bicepparam" + +echo "==> Deploying infrastructure (this takes ~5 minutes)..." +OUTPUTS=$(az deployment sub create \ + --name "$DEPLOYMENT_NAME" \ + --location eastus2 \ + --template-file "$(dirname "$0")/main.bicep" \ + --parameters "$(dirname "$0")/main.bicepparam" \ + --query properties.outputs \ + --output json) + +echo "" +echo "==> Deployment complete. Capture these values for GitHub secrets and SWA config:" +echo "" +echo " AZURE_OPENAI_ENDPOINT: $(echo "$OUTPUTS" | jq -r '.openAiEndpoint.value')" +echo " AZURE_OPENAI_DEPLOYMENT_NAME: $(echo "$OUTPUTS" | jq -r '.openAiDeploymentName.value')" +echo " NEXTAUTH_URL (prefix https://): $(echo "$OUTPUTS" | jq -r '.swaHostname.value')" +echo " AZURE_STATIC_WEB_APPS_API_TOKEN: $(echo "$OUTPUTS" | jq -r '.swaDeploymentToken.value')" +echo " Resource group: $(echo "$OUTPUTS" | jq -r '.resourceGroup.value')" +echo "" +echo "Next: retrieve the OpenAI API key and set GitHub + SWA secrets (see README)." diff --git a/wc-claims-portal/infra/main.bicep b/wc-claims-portal/infra/main.bicep new file mode 100644 index 0000000..a47a9ef --- /dev/null +++ b/wc-claims-portal/infra/main.bicep @@ -0,0 +1,82 @@ +targetScope = 'subscription' + +@description('Short environment tag (dev | staging | prod)') +param environment string = 'dev' + +@description('Azure region for all resources') +param location string = 'eastus2' + +@description('Workload prefix used in all resource names') +param prefix string = 'adaptcloud' + +@description('GPT model to deploy — must be available in the chosen region') +param openAiModelName string = 'gpt-4o' + +@description('Model version — verify availability in your region before changing') +param openAiModelVersion string = '2024-05-13' + +@description('Tokens-per-minute capacity (thousands) for the model deployment') +param openAiCapacityK int = 30 + +// ── Names ──────────────────────────────────────────────────────────────────── + +var rgName = 'rg-${prefix}-wc-claims-${environment}' +var openAiName = 'oai-${prefix}-wc-claims-${environment}' +var swaName = 'swa-${prefix}-wc-claims-${environment}' +var deploymentName = openAiModelName // deployment name matches the model for simplicity + +// ── Resource group ─────────────────────────────────────────────────────────── + +resource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = { + name: rgName + location: location + tags: { + workload: 'wc-claims-portal' + environment: environment + managedBy: 'bicep' + } +} + +// ── Modules ─────────────────────────────────────────────────────────────────── + +module openai 'modules/openai.bicep' = { + name: 'openai' + scope: rg + params: { + name: openAiName + location: location + modelName: openAiModelName + modelVersion: openAiModelVersion + deploymentName: deploymentName + capacityK: openAiCapacityK + environment: environment + } +} + +module swa 'modules/staticwebapp.bicep' = { + name: 'swa' + scope: rg + params: { + name: swaName + location: location + environment: environment + } +} + +// ── Outputs ─────────────────────────────────────────────────────────────────── + +@description('Resource group name') +output resourceGroup string = rg.name + +@description('Azure OpenAI endpoint — set as AZURE_OPENAI_ENDPOINT in SWA + GitHub secrets') +output openAiEndpoint string = openai.outputs.endpoint + +@description('Azure OpenAI deployment name — set as AZURE_OPENAI_DEPLOYMENT_NAME') +output openAiDeploymentName string = openai.outputs.deploymentName + +@description('Static Web App default hostname — set as NEXTAUTH_URL (prefix with https://)') +output swaHostname string = swa.outputs.hostname + +@description('Static Web App deployment token — set as AZURE_STATIC_WEB_APPS_API_TOKEN in GitHub secrets') +@secure() +output swaDeploymentToken string = swa.outputs.deploymentToken diff --git a/wc-claims-portal/infra/main.bicepparam b/wc-claims-portal/infra/main.bicepparam new file mode 100644 index 0000000..320be65 --- /dev/null +++ b/wc-claims-portal/infra/main.bicepparam @@ -0,0 +1,12 @@ +using './main.bicep' + +// ── Dev Landing Zone parameters ─────────────────────────────────────────────── +// Adjust location to match your dev subscription's approved regions. +// Run: az account list-locations -o table + +param environment = 'dev' +param location = 'eastus2' +param prefix = 'adaptcloud' +param openAiModelName = 'gpt-4o' +param openAiModelVersion = '2024-05-13' +param openAiCapacityK = 30 diff --git a/wc-claims-portal/infra/modules/openai.bicep b/wc-claims-portal/infra/modules/openai.bicep new file mode 100644 index 0000000..737e8e7 --- /dev/null +++ b/wc-claims-portal/infra/modules/openai.bicep @@ -0,0 +1,47 @@ +param name string +param location string +param modelName string +param modelVersion string +param deploymentName string +param capacityK int +param environment string + +resource account 'Microsoft.CognitiveServices/accounts@2023-10-01-preview' = { + name: name + location: location + kind: 'OpenAI' + sku: { + name: 'S0' + } + properties: { + customSubDomainName: name + publicNetworkAccess: 'Enabled' + // Restrict to your landing zone's allowed networks if required by policy + // networkAcls: { defaultAction: 'Deny', ipRules: [], virtualNetworkRules: [] } + } + tags: { + workload: 'wc-claims-portal' + environment: environment + } +} + +resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2023-10-01-preview' = { + parent: account + name: deploymentName + sku: { + name: 'Standard' + capacity: capacityK + } + properties: { + model: { + format: 'OpenAI' + name: modelName + version: modelVersion + } + versionUpgradeOption: 'OnceNewDefaultVersionAvailable' + } +} + +output endpoint string = account.properties.endpoint +output deploymentName string = deployment.name +output accountName string = account.name diff --git a/wc-claims-portal/infra/modules/staticwebapp.bicep b/wc-claims-portal/infra/modules/staticwebapp.bicep new file mode 100644 index 0000000..9a268f6 --- /dev/null +++ b/wc-claims-portal/infra/modules/staticwebapp.bicep @@ -0,0 +1,28 @@ +param name string +param location string +param environment string + +// Standard tier is required — Free tier does not support Next.js API routes +resource swa 'Microsoft.Web/staticSites@2023-01-01' = { + name: name + location: location + sku: { + name: 'Standard' + tier: 'Standard' + } + properties: { + stagingEnvironmentPolicy: 'Enabled' + allowConfigFileUpdates: true + enterpriseGradeCdnStatus: 'Disabled' + } + tags: { + workload: 'wc-claims-portal' + environment: environment + } +} + +output hostname string = swa.properties.defaultHostname +output swaName string = swa.name + +@secure() +output deploymentToken string = swa.listSecrets().properties.apiKey diff --git a/wc-claims-portal/next.config.ts b/wc-claims-portal/next.config.ts new file mode 100644 index 0000000..43ad6a7 --- /dev/null +++ b/wc-claims-portal/next.config.ts @@ -0,0 +1,16 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + serverExternalPackages: ["@azure/openai"], + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "*.microsoftonline.com", + }, + ], + }, +}; + +export default nextConfig; diff --git a/wc-claims-portal/package.json b/wc-claims-portal/package.json new file mode 100644 index 0000000..17e805e --- /dev/null +++ b/wc-claims-portal/package.json @@ -0,0 +1,36 @@ +{ + "name": "wc-claims-portal", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@azure/openai": "1.0.0-beta.12", + "next": "14.2.29", + "next-auth": "^4.24.10", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", + "clsx": "^2.1.1", + "tailwind-merge": "^2.5.4", + "lucide-react": "^0.468.0", + "date-fns": "^4.1.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-config-next": "14.2.29", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2" + } +} diff --git a/wc-claims-portal/postcss.config.js b/wc-claims-portal/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/wc-claims-portal/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/wc-claims-portal/src/app/api/auth/[...nextauth]/route.ts b/wc-claims-portal/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..7b38c1b --- /dev/null +++ b/wc-claims-portal/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/wc-claims-portal/src/app/api/chat/route.ts b/wc-claims-portal/src/app/api/chat/route.ts new file mode 100644 index 0000000..76c3431 --- /dev/null +++ b/wc-claims-portal/src/app/api/chat/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth"; +import { + getAzureOpenAIClient, + DEPLOYMENT, + WC_SYSTEM_PROMPT, + type ChatRequestMessage, +} from "@/lib/azure-openai"; +import { z } from "zod"; +import { ROLES, forbiddenIfMissingRole } from "@/lib/rbac"; +import { auditLog, buildAuditEvent } from "@/lib/audit"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +// --------------------------------------------------------------------------- +// Per-user sliding window rate limiter — 20 requests / 60 seconds. +// Resets on cold start; sufficient to prevent runaway Azure OpenAI spend. +// --------------------------------------------------------------------------- +const _windows = new Map(); + +function isRateLimited(userId: string): boolean { + const now = Date.now(); + const window = (_windows.get(userId) ?? []).filter((t) => now - t < 60_000); + if (window.length >= 20) return true; + window.push(now); + _windows.set(userId, window); + return false; +} + +const ChatRequestSchema = z.object({ + messages: z + .array( + z.object({ + role: z.enum(["user", "assistant"]), + content: z.string().min(1).max(32_000), + }) + ) + .min(1) + .max(50), + claimId: z.string().optional(), + jurisdiction: z.string().length(2).toUpperCase().optional(), +}); + +export async function POST(req: NextRequest) { + // Require authenticated session + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // RBAC — only adjusters and supervisors may invoke the AI + const rbacDenied = forbiddenIfMissingRole( + session.user.roles, + ROLES.ADJUSTER, + ROLES.SUPERVISOR + ); + if (rbacDenied) return rbacDenied; + + const userId = session.user.oid ?? session.user.email ?? "unknown"; + if (isRateLimited(userId)) { + auditLog(buildAuditEvent("chat.rate_limited", session.user)); + return NextResponse.json( + { error: "Too many requests. Please wait a moment before sending another message." }, + { status: 429 } + ); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = ChatRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Bad request", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { messages, claimId, jurisdiction } = parsed.data; + + auditLog( + buildAuditEvent("chat.request", session.user, { + claimId, + jurisdiction, + metadata: { messageCount: messages.length }, + }) + ); + + let systemContent = WC_SYSTEM_PROMPT; + if (claimId || jurisdiction) { + const ctx = ["## Active Claim Context"]; + if (claimId) ctx.push(`Claim ID: ${claimId}`); + if (jurisdiction) ctx.push(`Jurisdiction: ${jurisdiction} — apply ${jurisdiction} Workers' Compensation statutes, deadlines, and form requirements.`); + systemContent += "\n\n" + ctx.join("\n"); + } + + const client = getAzureOpenAIClient(); + + const chatMessages: ChatRequestMessage[] = [ + { role: "system", content: systemContent }, + ...messages, + ]; + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + try { + const events = await client.streamChatCompletions( + DEPLOYMENT, + chatMessages, + { maxTokens: 2048, temperature: 0.2 } + ); + + for await (const event of events) { + for (const choice of event.choices) { + const delta = choice.delta?.content; + if (delta) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ content: delta })}\n\n`) + ); + } + } + } + + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + } catch (err) { + const message = + err instanceof Error ? err.message : "Azure OpenAI error"; + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ error: message })}\n\n`) + ); + } finally { + controller.close(); + } + }, + }); + + return new NextResponse(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/wc-claims-portal/src/app/api/claims/[id]/route.ts b/wc-claims-portal/src/app/api/claims/[id]/route.ts new file mode 100644 index 0000000..f5c17c3 --- /dev/null +++ b/wc-claims-portal/src/app/api/claims/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth"; +import { getClaimsAdapter } from "@/lib/claims"; +import { auditLog, buildAuditEvent } from "@/lib/audit"; + +export const runtime = "nodejs"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + auditLog(buildAuditEvent("claim.view", session.user, { claimId: id })); + + try { + const adapter = getClaimsAdapter(); + const claim = await adapter.getClaim(id); + if (!claim) { + return NextResponse.json({ error: "Claim not found" }, { status: 404 }); + } + return NextResponse.json(claim); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load claim"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/wc-claims-portal/src/app/api/claims/route.ts b/wc-claims-portal/src/app/api/claims/route.ts new file mode 100644 index 0000000..a4264a9 --- /dev/null +++ b/wc-claims-portal/src/app/api/claims/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth"; +import { getClaimsAdapter } from "@/lib/claims"; +import { auditLog, buildAuditEvent } from "@/lib/audit"; + +export const runtime = "nodejs"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + auditLog(buildAuditEvent("claims.list", session.user)); + + try { + const adapter = getClaimsAdapter(); + const claims = await adapter.listClaims(); + return NextResponse.json(claims); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load claims"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/wc-claims-portal/src/app/auth/error/page.tsx b/wc-claims-portal/src/app/auth/error/page.tsx new file mode 100644 index 0000000..e875ae5 --- /dev/null +++ b/wc-claims-portal/src/app/auth/error/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Link from "next/link"; +import { AlertTriangle } from "lucide-react"; + +export default function AuthError() { + return ( +
+
+ +

+ Authentication Error +

+

+ There was a problem signing you in. Your account may not be authorized + to access this portal. +

+ + Try Again + +
+
+ ); +} diff --git a/wc-claims-portal/src/app/auth/signin/page.tsx b/wc-claims-portal/src/app/auth/signin/page.tsx new file mode 100644 index 0000000..51d0767 --- /dev/null +++ b/wc-claims-portal/src/app/auth/signin/page.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { ShieldCheck } from "lucide-react"; + +function SignInContent() { + const params = useSearchParams(); + const callbackUrl = params.get("callbackUrl") ?? "/dashboard"; + const error = params.get("error"); + + return ( +
+
+
+
+ +
+

+ {process.env.NEXT_PUBLIC_APP_NAME ?? "WC Claims Agent Portal"} +

+

+ {process.env.NEXT_PUBLIC_ORG_NAME ?? "AdaptCloud"} +

+
+ + {error && ( +
+ Authentication failed. Please try again or contact your + administrator. +
+ )} + + + +

+ Access restricted to authorized claims personnel only. +

+
+
+ ); +} + +function MicrosoftIcon() { + return ( + + ); +} + +export default function SignInPage() { + return ( + + + + ); +} diff --git a/wc-claims-portal/src/app/claims/[id]/page.tsx b/wc-claims-portal/src/app/claims/[id]/page.tsx new file mode 100644 index 0000000..dba0af5 --- /dev/null +++ b/wc-claims-portal/src/app/claims/[id]/page.tsx @@ -0,0 +1,35 @@ +import { getServerSession } from "next-auth/next"; +import { redirect, notFound } from "next/navigation"; +import { authOptions } from "@/lib/auth"; +import { getClaimsAdapter } from "@/lib/claims"; +import { AppShell } from "@/components/ui/AppShell"; +import { ClaimDetail } from "@/components/claims/ClaimDetail"; +import { ChatPanel } from "@/components/chat/ChatPanel"; + +interface Props { + params: Promise<{ id: string }>; +} + +export default async function ClaimPage({ params }: Props) { + const session = await getServerSession(authOptions); + if (!session) redirect("/auth/signin"); + + const { id } = await params; + const claim = await getClaimsAdapter().getClaim(id); + if (!claim) notFound(); + + return ( + +
+
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/wc-claims-portal/src/app/claims/page.tsx b/wc-claims-portal/src/app/claims/page.tsx new file mode 100644 index 0000000..acdabd3 --- /dev/null +++ b/wc-claims-portal/src/app/claims/page.tsx @@ -0,0 +1,27 @@ +import { getServerSession } from "next-auth/next"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/lib/auth"; +import { getClaimsAdapter } from "@/lib/claims"; +import { AppShell } from "@/components/ui/AppShell"; +import { ClaimsTable } from "@/components/claims/ClaimsTable"; + +export default async function ClaimsPage() { + const session = await getServerSession(authOptions); + if (!session) redirect("/auth/signin"); + + const claims = await getClaimsAdapter().listClaims(); + + return ( + +
+
+

Claims

+

+ Manage and review workers' compensation claims +

+
+ +
+
+ ); +} diff --git a/wc-claims-portal/src/app/dashboard/page.tsx b/wc-claims-portal/src/app/dashboard/page.tsx new file mode 100644 index 0000000..2124126 --- /dev/null +++ b/wc-claims-portal/src/app/dashboard/page.tsx @@ -0,0 +1,43 @@ +import { getServerSession } from "next-auth/next"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/lib/auth"; +import { getClaimsAdapter } from "@/lib/claims"; +import { AppShell } from "@/components/ui/AppShell"; +import { DashboardStats } from "@/components/claims/DashboardStats"; +import { RecentActivity } from "@/components/claims/RecentActivity"; + +export default async function DashboardPage() { + const session = await getServerSession(authOptions); + if (!session) redirect("/auth/signin"); + + const adapter = getClaimsAdapter(); + const [metrics, activity] = await Promise.all([ + adapter.getDashboardMetrics(), + adapter.getRecentActivity(), + ]); + + return ( + +
+
+

+ Welcome back, {session.user.name?.split(" ")[0]} +

+

+ WC Claims Dashboard —{" "} + {new Date().toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + })} +

+
+ +
+ +
+
+
+ ); +} diff --git a/wc-claims-portal/src/app/globals.css b/wc-claims-portal/src/app/globals.css new file mode 100644 index 0000000..3660754 --- /dev/null +++ b/wc-claims-portal/src/app/globals.css @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --bg: 248 250 252; + --surface: 255 255 255; + } + + body { + @apply bg-slate-50 text-slate-900 antialiased; + } +} + +@layer utilities { + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: theme(colors.slate.300) transparent; + } +} diff --git a/wc-claims-portal/src/app/layout.tsx b/wc-claims-portal/src/app/layout.tsx new file mode 100644 index 0000000..4718b0f --- /dev/null +++ b/wc-claims-portal/src/app/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { AuthProvider } from "@/components/ui/AuthProvider"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: process.env.NEXT_PUBLIC_APP_NAME ?? "WC Claims Agent Portal", + description: "Workers' Compensation claims management powered by AI", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/wc-claims-portal/src/app/page.tsx b/wc-claims-portal/src/app/page.tsx new file mode 100644 index 0000000..775821e --- /dev/null +++ b/wc-claims-portal/src/app/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth"; + +export default async function Home() { + const session = await getServerSession(authOptions); + if (!session) { + redirect("/auth/signin"); + } + redirect("/dashboard"); +} diff --git a/wc-claims-portal/src/components/chat/ChatPanel.tsx b/wc-claims-portal/src/components/chat/ChatPanel.tsx new file mode 100644 index 0000000..aa86213 --- /dev/null +++ b/wc-claims-portal/src/components/chat/ChatPanel.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; + +const uid = () => crypto.randomUUID(); +import { Send, Bot, User, Loader2 } from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import { clsx } from "clsx"; +import type { Message } from "@/types/chat"; + +interface Props { + claimId?: string; + jurisdiction?: string; +} + +export function ChatPanel({ claimId, jurisdiction }: Props) { + const [messages, setMessages] = useState([ + { + id: uid(), + role: "assistant", + content: claimId + ? `I'm your WC claims assistant. I have context for claim **${claimId}**${jurisdiction ? ` (${jurisdiction} jurisdiction)` : ""}. How can I help you with this claim today?` + : "I'm your WC claims assistant. Ask me about any claim, coverage analysis, return-to-work planning, reserves, or jurisdiction requirements.", + createdAt: new Date(), + }, + ]); + const [input, setInput] = useState(""); + const [streaming, setStreaming] = useState(false); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const send = useCallback(async () => { + const content = input.trim(); + if (!content || streaming) return; + + const userMsg: Message = { + id: uid(), + role: "user", + content, + createdAt: new Date(), + }; + + setMessages((prev) => [...prev, userMsg]); + setInput(""); + setStreaming(true); + + const assistantId = uid(); + setMessages((prev) => [ + ...prev, + { id: assistantId, role: "assistant", content: "", createdAt: new Date() }, + ]); + + try { + const history = [...messages, userMsg] + .filter((m) => m.role !== "system") + .map(({ role, content }) => ({ role, content })); + + const res = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: history, claimId, jurisdiction }), + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const reader = res.body?.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + if (!reader) throw new Error("No response body"); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6); + if (data === "[DONE]") break; + + let parsed: { content?: string; error?: string }; + try { + parsed = JSON.parse(data); + } catch { + continue; // ignore malformed chunks + } + if (parsed.error) throw new Error(parsed.error); + if (parsed.content) { + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, content: m.content + parsed.content } + : m + ) + ); + } + } + } + } catch (err) { + const errorText = + err instanceof Error ? err.message : "Something went wrong"; + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + content: `_Error: ${errorText}. Please try again._`, + } + : m + ) + ); + } finally { + setStreaming(false); + } + }, [input, streaming, messages, claimId]); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + }; + + return ( +
+
+ + + WC Claims Assistant + + {claimId && ( + + {claimId} + + )} +
+ +
+ {messages.map((msg) => ( + + ))} +
+
+ +
+
+