Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d1fbfa5
feat: deploy EHR demo to Azure via Aspire + azd → ACA
rsalus Mar 15, 2026
68f1807
Merge feat/deploy-ehr-demo: Azure Container Apps deployment via Aspir…
rsalus Mar 15, 2026
05f0b3b
fix: use absolute URL for GraphQL client (graphql-request requires it)
rsalus Mar 15, 2026
a11c85a
feat(pa-dashboard-demo): task-001 — DemoProvider context with scene/c…
rsalus Mar 15, 2026
995262d
feat(pa-dashboard-demo): task-002 — SceneNav pill navigation component
rsalus Mar 15, 2026
24ed344
feat(pa-dashboard-demo): task-003 — SceneTransition wrapper and route…
rsalus Mar 15, 2026
1ef87ce
feat(pa-dashboard-demo): task-017 — backend seed script
rsalus Mar 15, 2026
ad68826
Merge branch 'worktree-agent-a4317a39'
rsalus Mar 15, 2026
5c0b58c
Merge branch 'worktree-agent-a3594ddb'
rsalus Mar 15, 2026
8f8fa01
feat(pa-dashboard-demo): task-004 -- fleet seed data and KPICards com…
rsalus Mar 15, 2026
a09be2f
feat(pa-dashboard-demo): task-008 -- enhanced demo data and ChartTabP…
rsalus Mar 15, 2026
5dc4dfb
feat(pa-dashboard-demo): task-005 -- FleetCard and FleetView components
rsalus Mar 15, 2026
47edb49
feat(pa-dashboard-demo): task-012 — React Flow custom nodes
rsalus Mar 15, 2026
ecff8ca
feat(pa-dashboard-demo): task-006 -- CasePipeline component
rsalus Mar 15, 2026
addc0ee
feat(pa-dashboard-demo): task-009 -- enhanced EncounterSidebar with c…
rsalus Mar 15, 2026
39f3431
feat(pa-dashboard-demo): task-013 — CaseGraph and AnimatedEdge
rsalus Mar 15, 2026
682176c
feat(pa-dashboard-demo): task-007 -- Fleet route page assembly and Gr…
rsalus Mar 15, 2026
470120a
feat(pa-dashboard-demo): task-010 -- enhanced useEhrDemoFlow state ma…
rsalus Mar 15, 2026
68bfd69
Merge branch 'worktree-agent-adad11f1'
rsalus Mar 15, 2026
c5b3783
feat(pa-dashboard-demo): task-011 -- authorization detection banner a…
rsalus Mar 15, 2026
6a48103
feat(pa-dashboard-demo): task-014 — CaseTimeline and case detail route
rsalus Mar 15, 2026
a5f953d
Merge branch 'worktree-agent-a178e330'
rsalus Mar 15, 2026
98157da
Merge branch 'worktree-agent-a2fd1d4e'
rsalus Mar 15, 2026
78d11ee
feat(pa-dashboard-demo): task-015 -- scene transitions with shared la…
rsalus Mar 15, 2026
7181c21
feat(pa-dashboard-demo): task-016 -- integration polish and demo entr…
rsalus Mar 15, 2026
965f146
feat(pa-dashboard-demo): merge integration layer with conflict resolu…
rsalus Mar 15, 2026
a8a2aa7
fix(pa-dashboard-demo): align SceneNav tests with aria-current implem…
rsalus Mar 15, 2026
1f27f80
fix(pa-dashboard-demo): address all PR review findings
rsalus Mar 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/dashboard/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +23 to +34
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden runtime image to avoid running as root.

The final stage lacks a USER directive, and scanner DS-0002 flags this. Please switch to an unprivileged nginx image/user and align exposed/healthcheck port accordingly.

Suggested hardening baseline
-FROM docker.io/nginx:alpine
+FROM docker.io/nginxinc/nginx-unprivileged:stable-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
+EXPOSE 8080

 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
-    CMD wget -q --spider http://localhost/health || exit 1
+    CMD wget -q --spider http://localhost:8080/health || exit 1

Also ensure nginx is configured to listen on 8080 to match this change.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
FROM docker.io/nginxinc/nginx-unprivileged:stable-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 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -q --spider http://localhost:8080/health || exit 1
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/Dockerfile` around lines 23 - 34, The final Dockerfile stage
runs nginx as root and must be hardened by switching to an unprivileged runtime
user and adjusting ports; update the Dockerfile to run nginx under a non-root
user (e.g., USER nginx or create a low-privilege user and set appropriate
ownership on /usr/share/nginx/html), change EXPOSE 80 to EXPOSE 8080, and update
the HEALTHCHECK to probe http://localhost:8080/health; also update the nginx
config template referenced by COPY apps/dashboard/nginx.conf (ensure the server
listen directive uses 8080) so nginx actually listens on the new port.

32 changes: 32 additions & 0 deletions apps/dashboard/nginx.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@
"@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",
"graphql-request": "^7.0.0",
"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",
Expand Down
8 changes: 2 additions & 6 deletions apps/dashboard/src/api/graphqlClient.ts
Original file line number Diff line number Diff line change
@@ -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 = `${window.location.origin}/api/graphql`;

export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, {
credentials: 'include',
Expand Down
57 changes: 57 additions & 0 deletions apps/dashboard/src/components/case/AnimatedEdge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { getSmoothStepPath, BaseEdge, type EdgeProps } from '@xyflow/react';

export interface AnimatedEdgeData {
animationDelay?: number;
}

/**
* Custom React Flow edge with an animated dot traveling along the path.
* Uses SVG <circle> with <animateMotion> for the animation effect.
*/
export function AnimatedEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
data,
}: EdgeProps & { data?: AnimatedEdgeData }) {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});

const delay = data?.animationDelay ?? 0;

return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
...style,
strokeDasharray: '6 4',
strokeWidth: 1.5,
stroke: '#94a3b8',
}}
/>
<circle r="3" fill="#0d9488" opacity="0.8">
<animateMotion
dur="2.5s"
repeatCount="indefinite"
path={edgePath}
begin={`${delay}s`}
/>
</circle>
</>
);
}
235 changes: 235 additions & 0 deletions apps/dashboard/src/components/case/CaseGraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { useState, useEffect, useCallback } from 'react';
import { ReactFlow, ReactFlowProvider, Background, Controls } from '@xyflow/react';
import type { Node, Edge } from '@xyflow/react';
import { PatientNode } from './PatientNode';
import { EvidenceNode } from './EvidenceNode';
import { CriteriaNode } from './CriteriaNode';
import { DecisionNode } from './DecisionNode';
import { AnimatedEdge } from './AnimatedEdge';
import { DEMO_PA_RESULT_SOURCES, LCD_L34220_POLICY } from '@/lib/demoData';
import type { PARequest } from '@/api/graphqlService';

// Register custom node and edge types
const nodeTypes = {
patient: PatientNode,
evidence: EvidenceNode,
criteria: CriteriaNode,
decision: DecisionNode,
};

const edgeTypes = {
animated: AnimatedEdge,
};

/**
* Maps a criterion's met value (boolean | null) to a node status string.
*/
function toStatus(met: boolean | null): 'met' | 'not_met' | 'indeterminate' {
if (met === true) return 'met';
if (met === false) return 'not_met';
return 'indeterminate';
}

/**
* Builds graph nodes and edges from a PARequest.
* Exported for testability.
*/
export function buildCaseGraphData(paRequest: PARequest): {

Check warning on line 37 in apps/dashboard/src/components/case/CaseGraph.tsx

View workflow job for this annotation

GitHub Actions / Dashboard Build & Test

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
nodes: Node[];
edges: Edge[];
} {
const nodes: Node[] = [];
const edges: Edge[] = [];

// 1. Patient node (top center)
const patientNodeId = 'patient-1';
nodes.push({
id: patientNodeId,
type: 'patient',
position: { x: 400, y: 0 },
data: {
name: paRequest.patient.name,
dob: paRequest.patient.dob,
mrn: paRequest.patient.mrn,
insurance: paRequest.payer,
},
});

// 2. Evidence nodes (left column, stacked)
const evidenceEntries = paRequest.criteria.map((criterion) => {
const sourceInfo = DEMO_PA_RESULT_SOURCES[criterion.label];
return {
text: sourceInfo?.evidence ?? criterion.reason ?? criterion.label,
source: sourceInfo?.source ?? 'Clinical',
criterionLabel: criterion.label,
};
});

const evidenceYStart = 120;
const evidenceSpacing = 100;

evidenceEntries.forEach((entry, i) => {
const nodeId = `evidence-${i}`;
nodes.push({
id: nodeId,
type: 'evidence',
position: { x: 0, y: evidenceYStart + i * evidenceSpacing },
data: {
text: entry.text,
source: entry.source,
},
});

// Edge: patient -> evidence (subtle)
edges.push({
id: `edge-patient-evidence-${i}`,
source: patientNodeId,
target: nodeId,
style: { stroke: '#cbd5e1', strokeWidth: 1 },
animated: false,
});
});

// 3. Criteria nodes (center column, stacked)
const criteriaYStart = 120;
const criteriaSpacing = 100;

paRequest.criteria.forEach((criterion, i) => {
const nodeId = `criteria-${i}`;
nodes.push({
id: nodeId,
type: 'criteria',
position: { x: 350, y: criteriaYStart + i * criteriaSpacing },
data: {
label: criterion.label,
status: toStatus(criterion.met),
reasoning: criterion.reason,
},
});

// Edge: evidence -> criteria (animated dashed)
edges.push({
id: `edge-evidence-criteria-${i}`,
source: `evidence-${i}`,
target: nodeId,
type: 'animated',
data: { animationDelay: i * 0.3 },
});
});

// 4. Decision node (right)
const decisionNodeId = 'decision-1';
const decisionY =
criteriaYStart +
((paRequest.criteria.length - 1) * criteriaSpacing) / 2;

nodes.push({
id: decisionNodeId,
type: 'decision',
position: { x: 700, y: decisionY },
data: {
payer: paRequest.payer,
policyId: LCD_L34220_POLICY.policyId,
confidence: paRequest.confidence,
status: paRequest.status,
},
});

// Edges: criteria -> decision (solid colored)
paRequest.criteria.forEach((criterion, i) => {
const statusColor =
criterion.met === true
? '#22c55e'
: criterion.met === false
? '#ef4444'
: '#f59e0b';
edges.push({
id: `edge-criteria-decision-${i}`,
source: `criteria-${i}`,
target: decisionNodeId,
style: { stroke: statusColor, strokeWidth: 2 },
});
});

return { nodes, edges };
}

/** Timing tiers for sequential node reveal animation (ms) */
const REVEAL_TIERS: Record<string, [number, number]> = {
patient: [0, 300],
evidence: [300, 900],
criteria: [900, 1500],
decision: [1500, 1800],
};

/** Apply opacity 0 initially, then reveal nodes sequentially by type */
function useNodeReveal(baseNodes: Node[]): Node[] {
const [revealedTypes, setRevealedTypes] = useState<Set<string>>(new Set());

const scheduleReveals = useCallback(() => {
const types = Object.keys(REVEAL_TIERS);
const timers: ReturnType<typeof setTimeout>[] = [];

for (const type of types) {
const [, end] = REVEAL_TIERS[type];
timers.push(
setTimeout(() => {
setRevealedTypes((prev) => new Set([...prev, type]));
}, end),
);
}

return timers;
}, []);

useEffect(() => {
const timers = scheduleReveals();
return () => timers.forEach(clearTimeout);
}, [scheduleReveals]);

return baseNodes.map((node) => ({
...node,
style: {
...node.style,
opacity: revealedTypes.has(node.type ?? '') ? 1 : 0,
transition: 'opacity 0.3s ease-in',
},
}));
}

interface CaseGraphProps {
paRequest: PARequest;
}

/**
* Full case graph visualization for a PA request.
* Shows patient -> evidence -> criteria -> decision flow.
* Nodes fade in sequentially: Patient -> Evidence -> Criteria -> Decision.
*/
export function CaseGraph({ paRequest }: CaseGraphProps) {
const { nodes: baseNodes, edges } = buildCaseGraphData(paRequest);
const nodes = useNodeReveal(baseNodes);

return (
<ReactFlowProvider>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag={false}
zoomOnScroll={false}
zoomOnDoubleClick={false}
proOptions={{ hideAttribution: true }}
className="bg-slate-50/50 rounded-xl"
>
<Background gap={24} size={1} color="#e2e8f0" />
<Controls showInteractive={false} />
</ReactFlow>
</ReactFlowProvider>
);
}
Loading
Loading