|
| 1 | +--- |
| 2 | +name: aidbox-dashboard |
| 3 | +description: Creating dashboards on top of Aidbox FHIR Server using ViewDefinitions |
| 4 | +--- |
| 5 | + |
| 6 | +# Aidbox Dashboard |
| 7 | + |
| 8 | +## Aidbox FHIR Server |
| 9 | + |
| 10 | +Aidbox runs locally on port 8080 via Docker Compose. All FHIR API requests use the `/fhir/` prefix. |
| 11 | + |
| 12 | +## Creating Dashboards |
| 13 | + |
| 14 | +Dashboards are built using the **SQL on FHIR** approach: |
| 15 | + |
| 16 | +1. **Define a ViewDefinition** — a FHIR resource that describes how to flatten a FHIR resource into tabular columns |
| 17 | +2. **Upload and materialize** — run `bun run build:init-bundle --upload` to rebuild the init bundle, upload it to Aidbox, and materialize all ViewDefinitions into SQL tables in the `sof` schema |
| 18 | +3. **Query with SQL** — the app uses `Bun.SQL` to connect directly to PostgreSQL and queries `sof.<view_name>` for dashboard data |
| 19 | +4. **Render a chart** — add a Chart.js chart component in `src/index.ts` and wire it into the appropriate route handler |
| 20 | + |
| 21 | +### Step 1: Create a ViewDefinition |
| 22 | + |
| 23 | +ViewDefinitions are stored as JSON files in `fhir/definitions/view-definitions/`. Use numeric prefixes for ordering (e.g., `01-body-weight.json`). |
| 24 | + |
| 25 | +Each file is a bundle entry with a PUT request: |
| 26 | + |
| 27 | +```json |
| 28 | +{ |
| 29 | + "request": { |
| 30 | + "method": "PUT", |
| 31 | + "url": "/ViewDefinition/patient-demographics" |
| 32 | + }, |
| 33 | + "resource": { |
| 34 | + "resourceType": "ViewDefinition", |
| 35 | + "id": "patient-demographics", |
| 36 | + "name": "patient_demographics", |
| 37 | + "status": "active", |
| 38 | + "resource": "Patient", |
| 39 | + "select": [ |
| 40 | + { |
| 41 | + "column": [ |
| 42 | + { "path": "getResourceKey()", "name": "id" }, |
| 43 | + { "path": "gender", "name": "gender" }, |
| 44 | + { "path": "birthDate", "name": "birth_date" } |
| 45 | + ] |
| 46 | + }, |
| 47 | + { |
| 48 | + "forEachOrNull": "name.where(use = 'official').first()", |
| 49 | + "column": [ |
| 50 | + { "path": "given.join(' ')", "name": "given_name" }, |
| 51 | + { "path": "family", "name": "family_name" } |
| 52 | + ] |
| 53 | + } |
| 54 | + ] |
| 55 | + } |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +### Step 2: Build, upload, and materialize |
| 60 | + |
| 61 | +After creating or editing a ViewDefinition file, run: |
| 62 | + |
| 63 | +```sh |
| 64 | +bun run build:init-bundle --upload |
| 65 | +``` |
| 66 | + |
| 67 | +This single command: |
| 68 | +1. Rebuilds `init-bundle.json` from all files in `fhir/definitions/` |
| 69 | +2. Uploads the bundle to Aidbox via the FHIR API (using the root client) |
| 70 | +3. Calls `$materialize` on each ViewDefinition to create/refresh the corresponding SQL table in the `sof` schema |
| 71 | + |
| 72 | +Without `--upload`, it only rebuilds the JSON file locally. |
| 73 | + |
| 74 | +### Step 3: Query the view with SQL |
| 75 | + |
| 76 | +The app uses `Bun.SQL` (configured in `src/index.ts`) to query the `sof` schema directly: |
| 77 | + |
| 78 | +```ts |
| 79 | +import { SQL } from "bun"; |
| 80 | + |
| 81 | +const db = new SQL({ |
| 82 | + url: process.env.DATABASE_URL ?? "postgresql://aidbox:<POSTGRES_PASSWORD>@localhost:5432/aidbox", |
| 83 | +}); |
| 84 | + |
| 85 | +// Query a materialized view |
| 86 | +const rows = await db.unsafe( |
| 87 | + `SELECT effective_date, weight_kg, unit FROM sof.body_weight WHERE patient_id = $1 ORDER BY effective_date`, |
| 88 | + [patientId], |
| 89 | +); |
| 90 | +``` |
| 91 | + |
| 92 | +Get the actual `POSTGRES_PASSWORD` from `docker-compose.yaml` (`services.postgres.environment.POSTGRES_PASSWORD`). |
| 93 | + |
| 94 | +### Step 4: Render a chart with Chart.js |
| 95 | + |
| 96 | +Charts are rendered using [Chart.js v4](https://www.chartjs.org/) loaded via CDN (`<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>` in the Layout `<head>`). |
| 97 | + |
| 98 | +See `BodyWeightChart` in `src/index.ts` for the existing pattern: |
| 99 | + |
| 100 | +1. Define a TypeScript interface for the query result rows |
| 101 | +2. Create a function that returns a `<canvas>` element + an inline `<script>` that calls `new Chart()` |
| 102 | +3. Pass SQL result data as JSON-serialized `labels` and `data` arrays into the Chart.js config |
| 103 | +4. Call the function in the route handler and insert the result into the Layout |
| 104 | + |
| 105 | +Example chart function pattern: |
| 106 | + |
| 107 | +```ts |
| 108 | +function MyChart({ data }: { data: MyDataPoint[] }) { |
| 109 | + if (data.length === 0) { |
| 110 | + return `<div class="empty">No data found</div>`; |
| 111 | + } |
| 112 | + |
| 113 | + const chartId = `my-chart-${++chartIdCounter}`; |
| 114 | + const labels = JSON.stringify(data.map((d) => d.date_column)); |
| 115 | + const values = JSON.stringify(data.map((d) => d.value_column)); |
| 116 | + |
| 117 | + return `<div class="card"> |
| 118 | + <canvas id="${chartId}"></canvas> |
| 119 | + <script> |
| 120 | + new Chart(document.getElementById('${chartId}'), { |
| 121 | + type: 'line', |
| 122 | + data: { |
| 123 | + labels: ${labels}, |
| 124 | + datasets: [{ |
| 125 | + label: 'My Label', |
| 126 | + data: ${values}, |
| 127 | + borderColor: '#2563eb', |
| 128 | + backgroundColor: 'rgba(37, 99, 235, 0.1)', |
| 129 | + fill: true, |
| 130 | + tension: 0.3, |
| 131 | + pointRadius: 5, |
| 132 | + pointHoverRadius: 7, |
| 133 | + pointBackgroundColor: '#2563eb', |
| 134 | + pointBorderColor: '#fff', |
| 135 | + pointBorderWidth: 2, |
| 136 | + }] |
| 137 | + }, |
| 138 | + options: { |
| 139 | + responsive: true, |
| 140 | + plugins: { |
| 141 | + legend: { display: false }, |
| 142 | + tooltip: { |
| 143 | + callbacks: { |
| 144 | + label: (ctx) => ctx.parsed.y + ' unit' |
| 145 | + } |
| 146 | + } |
| 147 | + }, |
| 148 | + scales: { |
| 149 | + x: { title: { display: true, text: 'Date' }, grid: { display: false } }, |
| 150 | + y: { title: { display: true, text: 'Value' }, grace: '5%' } |
| 151 | + } |
| 152 | + } |
| 153 | + }); |
| 154 | + </script> |
| 155 | + </div>`; |
| 156 | +} |
| 157 | +``` |
| 158 | + |
| 159 | +Chart.js supports many chart types: `line`, `bar`, `pie`, `doughnut`, `radar`, `scatter`, `bubble`. See [Chart.js docs](https://www.chartjs.org/docs/latest/) for the full API. |
| 160 | + |
| 161 | +Use a unique `chartId` per chart instance (via the `chartIdCounter`) to support multiple charts on one page. |
| 162 | + |
| 163 | +### Database connection |
| 164 | + |
| 165 | +PostgreSQL is exposed on port 5432. Credentials are in `docker-compose.yaml` under `services.postgres.environment`: |
| 166 | + |
| 167 | +| Parameter | Source in `docker-compose.yaml` | |
| 168 | +|-----------|-------------------------------| |
| 169 | +| Host | `localhost` | |
| 170 | +| Port | `5432` | |
| 171 | +| Database | `POSTGRES_DB` | |
| 172 | +| User | `POSTGRES_USER` | |
| 173 | +| Password | `POSTGRES_PASSWORD` | |
| 174 | + |
| 175 | +Connection string format: `postgresql://<POSTGRES_USER>:<POSTGRES_PASSWORD>@localhost:5432/<POSTGRES_DB>` |
| 176 | + |
| 177 | +## ViewDefinition Reference |
| 178 | + |
| 179 | +### Structure |
| 180 | + |
| 181 | +| Field | Required | Description | |
| 182 | +|-------|----------|-------------| |
| 183 | +| `resourceType` | yes | `"ViewDefinition"` | |
| 184 | +| `name` | yes | Database table name (used as `sof.<name>`). Must match `^[A-Za-z][A-Za-z0-9_]*$` | |
| 185 | +| `resource` | yes | Target FHIR resource type (e.g., `"Patient"`, `"Observation"`) | |
| 186 | +| `status` | yes | `"active"`, `"draft"`, `"retired"`, or `"unknown"` | |
| 187 | +| `select` | yes | Array of select blocks defining output columns | |
| 188 | +| `where` | no | Array of FHIRPath filter expressions | |
| 189 | +| `constant` | no | Named constants referenced as `%name` in FHIRPath | |
| 190 | + |
| 191 | +### Select block |
| 192 | + |
| 193 | +| Field | Description | |
| 194 | +|-------|-------------| |
| 195 | +| `column` | Array of `{ path, name }` — FHIRPath expression and output column name | |
| 196 | +| `forEach` | FHIRPath expression to iterate (creates multiple rows per resource) | |
| 197 | +| `forEachOrNull` | Like `forEach` but emits a row with nulls when the collection is empty | |
| 198 | +| `unionAll` | Combine multiple select structures | |
| 199 | +| `select` | Nested select (cross-join with parent) | |
| 200 | + |
| 201 | +### Common FHIRPath expressions |
| 202 | + |
| 203 | +| Expression | Description | |
| 204 | +|------------|-------------| |
| 205 | +| `getResourceKey()` | Resource ID | |
| 206 | +| `subject.getReferenceKey(Patient)` | Referenced Patient ID (for joins) | |
| 207 | +| `gender` | Direct field access | |
| 208 | +| `birthDate` | Direct field access | |
| 209 | +| `name.where(use = 'official').first()` | Filter and pick first | |
| 210 | +| `given.join(' ')` | Join array into string | |
| 211 | +| `effective.ofType(dateTime)` | Polymorphic field access | |
| 212 | +| `value.ofType(Quantity).value` | Quantity value | |
| 213 | +| `value.ofType(Quantity).unit` | Quantity unit | |
| 214 | +| `code.coding` | Iterate over codings | |
| 215 | +| `code.coding.where(system='http://loinc.org').first()` | Pick specific coding | |
| 216 | +| `code.coding.where(system = 'http://loinc.org' and code = '29463-7').exists()` | Filter by coding system + code | |
| 217 | + |
| 218 | +### Example: Body Weight ViewDefinition (with filter) |
| 219 | + |
| 220 | +```json |
| 221 | +{ |
| 222 | + "resourceType": "ViewDefinition", |
| 223 | + "id": "body-weight", |
| 224 | + "name": "body_weight", |
| 225 | + "status": "active", |
| 226 | + "resource": "Observation", |
| 227 | + "where": [ |
| 228 | + { |
| 229 | + "path": "code.coding.where(system = 'http://loinc.org' and code = '29463-7').exists()" |
| 230 | + } |
| 231 | + ], |
| 232 | + "select": [ |
| 233 | + { |
| 234 | + "column": [ |
| 235 | + { "path": "getResourceKey()", "name": "id" }, |
| 236 | + { "path": "subject.getReferenceKey(Patient)", "name": "patient_id" }, |
| 237 | + { "path": "effective.ofType(dateTime)", "name": "effective_date" }, |
| 238 | + { "path": "value.ofType(Quantity).value", "name": "weight_kg" }, |
| 239 | + { "path": "value.ofType(Quantity).unit", "name": "unit" }, |
| 240 | + { "path": "status", "name": "status" } |
| 241 | + ] |
| 242 | + } |
| 243 | + ] |
| 244 | +} |
| 245 | +``` |
| 246 | + |
| 247 | +This creates `sof.body_weight` with columns: `id`, `patient_id`, `effective_date`, `weight_kg`, `unit`, `status`. |
| 248 | + |
| 249 | +### Example: Generic Observation ViewDefinition |
| 250 | + |
| 251 | +```json |
| 252 | +{ |
| 253 | + "resourceType": "ViewDefinition", |
| 254 | + "id": "observation-values", |
| 255 | + "name": "observation_values", |
| 256 | + "status": "active", |
| 257 | + "resource": "Observation", |
| 258 | + "select": [ |
| 259 | + { |
| 260 | + "column": [ |
| 261 | + { "path": "getResourceKey()", "name": "id" }, |
| 262 | + { "path": "subject.getReferenceKey(Patient)", "name": "patient_id" }, |
| 263 | + { "path": "status", "name": "status" }, |
| 264 | + { "path": "effective.ofType(dateTime)", "name": "effective_date" }, |
| 265 | + { "path": "value.ofType(Quantity).value", "name": "value" }, |
| 266 | + { "path": "value.ofType(Quantity).unit", "name": "unit" } |
| 267 | + ] |
| 268 | + }, |
| 269 | + { |
| 270 | + "forEachOrNull": "code.coding.first()", |
| 271 | + "column": [ |
| 272 | + { "path": "system", "name": "code_system" }, |
| 273 | + { "path": "code", "name": "code" }, |
| 274 | + { "path": "display", "name": "code_display" } |
| 275 | + ] |
| 276 | + } |
| 277 | + ] |
| 278 | +} |
| 279 | +``` |
| 280 | + |
| 281 | +## Auth Clients |
| 282 | + |
| 283 | +Two clients are available. Look up passwords in `docker-compose.yaml` and `fhir/definitions/access-control/`: |
| 284 | + |
| 285 | +| Client | Username | Password source | Use for | |
| 286 | +|--------|----------|----------------|---------| |
| 287 | +| **Application** | `basic` | `fhir/definitions/access-control/01-client.json` (`resource.secret`) | Normal CRUD + transactions | |
| 288 | +| **Root** | `root` | `docker-compose.yaml` (`BOX_ROOT_CLIENT_SECRET`) | Admin operations, uploading init bundle | |
| 289 | + |
| 290 | +The **basic** client is used by the running app (`src/aidbox.ts`) for normal FHIR CRUD operations. The **root** client is only used by `build-init-bundle` script to upload the init bundle and materialize ViewDefinitions — operations that require admin-level access. |
| 291 | + |
| 292 | +## Querying Resources via FHIR API |
| 293 | + |
| 294 | +```sh |
| 295 | +# Read a specific resource (get BOX_ROOT_CLIENT_SECRET from docker-compose.yaml) |
| 296 | +curl -s -u "root:<BOX_ROOT_CLIENT_SECRET>" "http://localhost:8080/fhir/Patient/<id>" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()),null,2))' |
| 297 | + |
| 298 | +# Search resources |
| 299 | +curl -s -u "root:<BOX_ROOT_CLIENT_SECRET>" "http://localhost:8080/fhir/Patient?name=John&_count=10" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()),null,2))' |
| 300 | +``` |
| 301 | + |
| 302 | +Always use the `/fhir/` prefix. Without it, you get the Aidbox-native format instead of FHIR. |
| 303 | + |
| 304 | +## Init Bundle |
| 305 | + |
| 306 | +FHIR definitions live in `fhir/definitions/` as individual JSON files. **Never edit `init-bundle.json` directly.** |
| 307 | + |
| 308 | +Files are sorted by filename, so use numeric prefixes to control order (e.g., `01-client.json` loads before `02-access-policy.json`). |
| 309 | + |
| 310 | +```sh |
| 311 | +# Rebuild, upload, and materialize ViewDefinitions in one step |
| 312 | +bun run build:init-bundle --upload |
| 313 | + |
| 314 | +# Rebuild only (no upload) |
| 315 | +bun run build:init-bundle |
| 316 | +``` |
| 317 | + |
| 318 | +The init bundle is also auto-loaded on Aidbox startup via `BOX_INIT_BUNDLE` in `docker-compose.yaml`. Note: ViewDefinitions loaded this way still need a `$materialize` call to create the SQL tables. |
| 319 | + |
| 320 | +## Application Code |
| 321 | + |
| 322 | +The app uses `src/aidbox.ts` which wraps the `@health-samurai/aidbox-client` SDK: |
| 323 | + |
| 324 | +```ts |
| 325 | +import { aidbox } from "./aidbox"; |
| 326 | + |
| 327 | +// Read |
| 328 | +const result = await aidbox.read<Patient>({ type: "Patient", id: "pt-1" }); |
| 329 | + |
| 330 | +// Search |
| 331 | +const result = await aidbox.searchType({ type: "Patient", query: [["name", "John"], ["_count", "10"]] }); |
| 332 | + |
| 333 | +// Transaction |
| 334 | +await aidbox.transaction({ format: "application/fhir+json", bundle: { resourceType: "Bundle", type: "transaction", entry: [...] } }); |
| 335 | +``` |
| 336 | + |
| 337 | +For dashboard queries, use direct SQL via `Bun.SQL` against the `sof` schema instead of the FHIR API. |
| 338 | + |
| 339 | +## Debugging |
| 340 | + |
| 341 | +### Check Aidbox health |
| 342 | +```sh |
| 343 | +curl -s "http://localhost:8080/health" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()),null,2))' |
| 344 | +``` |
| 345 | + |
| 346 | +### Inspect a resource |
| 347 | +```sh |
| 348 | +curl -s -u "root:<BOX_ROOT_CLIENT_SECRET>" "http://localhost:8080/fhir/<ResourceType>/<id>" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()),null,2))' |
| 349 | +``` |
| 350 | + |
| 351 | +### List ViewDefinitions |
| 352 | +```sh |
| 353 | +curl -s -u "root:<BOX_ROOT_CLIENT_SECRET>" "http://localhost:8080/ViewDefinition?_count=50" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()),null,2))' |
| 354 | +``` |
| 355 | + |
| 356 | +### Test a SQL table exists |
| 357 | +```sh |
| 358 | +# Via docker exec |
| 359 | +docker compose exec postgres psql -U aidbox -d aidbox -c "SELECT * FROM sof.<view_name> LIMIT 5;" |
| 360 | +``` |
0 commit comments