pediatric-ai-scribe-v3/docs/logic/architecture.md
Daniel 503f5afaad feat: ED multi-stage UX, extensions polish, docs viewer + application-logic docs
Three concurrent themes from this session:

═══════════════════════════════════════════════════════════════════
ED ENCOUNTERS — per-stage cards + consolidate→MDM finalize
═══════════════════════════════════════════════════════════════════

UX redesign per Daniel's feedback ("every stage note should be shown,
if AI is told to modify that particular note then the modified version
is used in final mdm"):

- Each generated stage stays on screen as its own editable card with
  its own embedded "Don't Miss" panel. No more single rolling note
  element that gets replaced on each generation.
- gatherCurrentNotes() reads contenteditable text from each stage card
  before any operation (advance, finalize, persist) so inline edits
  flow into the next AI call and the final consolidate.
- Stage badge is now state-accurate. "Stage N (recording)" with yellow
  background after Add-more before generation; "Stage N" with gray
  after generation. Fixes the bug where the badge flipped to Stage 2
  the moment Add-more was clicked.
- Save & Done now runs TWO server-side AI calls in /finalize:
  1. edConsolidate (new prompt) → polished single final note that
     integrates every stage chronologically (HPI / ROS / PE / ED Course /
     A&P with disposition).
  2. edFinalize (rewritten with full inline 2023 AMA E/M element
     rubric — problems / data / risk definitions, level mapping with
     concrete examples) → MDM JSON.
- Two new cards render after finalize: blue-bordered Final Consolidated
  Note + green-bordered MDM. Stage cards become read-only.
- partial_data on the saved row now stores {stages, finalNote, mdm,
  finalized} so resume re-renders the full state.

Why two-call finalize: a single combined prompt makes the model cut
corners on one task. Two focused calls cost ~2× latency at the very end
of an encounter — acceptable since finalize is a one-time terminal
action, not a per-stage hot path.

Files: public/components/ed-encounter.html, public/js/ed-encounters.js,
src/routes/edEncounters.js, src/utils/prompts.js (edConsolidate added,
edFinalize rewritten).

═══════════════════════════════════════════════════════════════════
EXTENSIONS / PAGERS — visual polish
═══════════════════════════════════════════════════════════════════

Multiple iterations based on Daniel's feedback:

- Layout: align-items:flex-start so action buttons stay pinned top-right
  when long numbers wrap (was align-items:center → buttons drifted into
  the text area, causing visible overlap).
- Number: word-break:break-all + min-width:0 + font-feature-settings:tnum
  so long numbers wrap within their column instead of pushing under the
  buttons. Click-to-copy with a 0.55s green flash + ✓ copied badge.
- Phone/pager Font Awesome icon next to the number in the type color —
  at-a-glance type signal (replacing an earlier 3px left stripe that
  Daniel found visually bulky).
- Name: font-weight 700, font-size 14.5px, color g900, letter-spacing
  -0.012em — scan-target headline typography for long lists.
- Alternating subtle backgrounds by index (white vs #fafbfc) so a long
  list reads as distinct rows.
- Hover: card lifts 1px with a soft shadow; action buttons fade from
  55% to 100% opacity. Cubic-bezier transition on transform.
- Entrance: staggered fade-up animation per card (35ms × index, capped
  at 12). prefers-reduced-motion media query disables motion.
- Empty state: 48px FA icon + heading instead of plain gray text.

Files: public/js/extensions.js, public/css/styles.css.

═══════════════════════════════════════════════════════════════════
DOCS REORGANIZATION + APPLICATION-LOGIC DOCS + ADMIN VIEWER
═══════════════════════════════════════════════════════════════════

Document moves (preserving git history via git mv):
  BROWSER_WHISPER_SETUP.md          → docs/browser-whisper-setup.md
  BROWSER_WHISPER_TROUBLESHOOTING.md → docs/browser-whisper-troubleshooting.md
  DEVELOPER_GUIDE.md                → docs/developer-guide-extended.md
  EMBEDDINGS_SETUP.md               → docs/embeddings-setup.md
  FEATURES_EXPLAINED.md             → docs/features-explained.md
  IMPROVEMENTS.md                   → docs/improvements.md
  OPENID_SETUP.md                   → docs/openid-setup.md
  TRANSCRIPTION_OPTIONS.md          → docs/transcription-options.md
README.md updated with the new paths + a Documentation section that
links to docs/logic/ at the top.

New application-logic doc series (~8,300 lines total) at docs/logic/.
Built with 5 parallel doc-writing agents per Daniel's "use multiple
agents" directive. Each doc explains how a part of the app actually
works — application logic, data flow, design decisions, sacred zones,
how-to-extend recipes — at a depth that lets a new dev (or an AI
assistant) modify the code confidently.

  docs/logic/README.md                — index + recommended reading order
  docs/logic/architecture.md (2166 L) — frontend IIFE pattern, lazy tab
                                         load, backend route convention,
                                         schema, encryption, deployment
  docs/logic/clinical-notes.md (1546L) — every note tab + helper trio
  docs/logic/bedside-and-calculators.md (1373L) — bedside ES module
                                         pocket + calculators + PE Guide
                                         + suture selector
  docs/logic/auth-admin-learning.md (1281L) — auth (local+OIDC+2FA) +
                                         admin panel + Learning Hub
                                         (Quiz engine logic at sub-detail
                                         only — TODO follow-up)
  docs/logic/ai-and-voice.md (1128 L) — callAI 5-provider routing,
                                         prompts, voice/STT, helper trio
  docs/logic/ed-encounters.md (821 L) — multi-stage ED + MDM (this
                                         session's worked example)

Admin-only docs viewer:
- New route /api/admin/docs/{tree,file}: recursively walks docs/, returns
  the tree as JSON; /file?path=X validates path stays inside docs/ and
  renders markdown via marked. Both gated by req.user.role==='admin'.
- New tab "Docs" (book icon) in the sidebar, hidden by default and
  revealed in auth.js when user.role==='admin' (same pattern as the
  existing Admin and CMS tabs).
- New component public/components/admin-docs.html: split-pane layout
  with a tree sidebar + filter input + a markdown reader pane.
- New module public/js/admin-docs.js: lazy-loads the tree on first tab
  activation, renders collapsible folders, persists expanded state and
  last-opened path via UIState. Server-rendered HTML so no client
  markdown parser needed.
- CSS for the viewer (responsive split-pane, code-block styling, table
  scrolling, etc.).
- Mounted at /api/admin/docs (NOT /api) — important: mounting a router
  with router.use(authMiddleware) at /api accidentally 401s every other
  /api/* path (caught and fixed during testing — /api/health was 401'ing).

Files: docs/* (moved + new), README.md, public/components/admin-docs.html
(new), public/js/admin-docs.js (new), src/routes/adminDocs.js (new),
public/index.html (tab + section + script), public/js/auth.js (admin
gate + logout cleanup), public/css/styles.css (viewer styles), server.js
(mount).

═══════════════════════════════════════════════════════════════════
KNOWN GAPS (TODO follow-ups)
═══════════════════════════════════════════════════════════════════

- Learning Hub quiz engine (MCQ / multi-select / T-F scoring + attempt
  tracking + progress dashboard) is covered at the architectural level
  in docs/logic/auth-admin-learning.md but not drilled into the quiz
  data model and scoring flow. Worth a focused follow-up doc.
- ED finalize: if MDM step JSON parse fails, server returns 502 with
  the consolidated finalNote in the error payload, but client doesn't
  surface the partial result. Add a "MDM failed, retry" affordance.
- No e2e Playwright coverage for ED encounters or the new docs viewer.
2026-04-28 03:09:38 +02:00

2166 lines
90 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ped-ai — Application Architecture (Deep Dive)
> **Audience.** Engineers working in this repo (and Daniel six months from now).
> Assumes familiarity with Node, Express, Postgres, and vanilla DOM. No
> framework knowledge required — there isn't one.
>
> **Scope.** Top-to-bottom mechanical description of how the app is wired:
> the IIFE frontend, the Express composition root, the schema, encryption
> at rest, migrations, deployment, and the sacred zones you must not
> casually edit.
>
> Companion docs: `docs/architecture.md` (one-page overview Daniel
> maintains), `docs/database.md`, `docs/deployment.md`,
> `docs/configuration.md`. This file is the long-form version that pulls
> the threads together with file:line citations.
---
## 1. Overview
### What this is
ped-ai (PedScribe / Pediatric AI Scribe) is a **self-hosted, single-tenant
clinical documentation platform for pediatricians**. It runs as two Docker
containers — an Express app and a PostgreSQL database — behind a
user-supplied reverse proxy. The audience is one practice (often one
clinician), not a multi-tenant SaaS. There is no per-tenant isolation
because there is no concept of a tenant: every authenticated user shares
the same Postgres schema with row-level `user_id` scoping.
### Top-level shape
```
┌────────────────────────────────────────┐
│ Reverse proxy (Caddy / Nginx / etc.) │
│ TLS termination + host routing │
└────────────────────┬───────────────────┘
127.0.0.1:3552
┌────────────────────────────────────┴─────────────────────────────────┐
│ pediatric-ai-scribe (node:20-alpine, Express 4) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ static (public/) → vanilla JS SPA shell │ │
│ │ /api/* → 28+ Express routers │ │
│ │ /api/health → Docker healthcheck │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ │ TCP 5432 │ outbound HTTPS │
│ ▼ ▼ │
│ ┌──────────────┐ ┌────────────────────────┐ │
│ │ PostgreSQL │ │ AI provider │ │
│ │ pgvector │ │ Bedrock / Vertex / │ │
│ │ pg16 │ │ Azure / LiteLLM / │ │
│ │ │ │ OpenRouter │ │
│ └──────────────┘ └────────────────────────┘ │
│ │
│ Optional siblings: Loki (logs), Grafana (metrics), │
│ OpenBao (secrets), Nextcloud (WebDAV upload), S3 (documents) │
└──────────────────────────────────────────────────────────────────────┘
```
### Tech stack
| Layer | Choice |
| ---------------- | ------------------------------------------------------------ |
| Runtime | Node.js 20 (alpine) |
| HTTP | Express 4 + helmet + cors + cookie-parser + express-rate-limit |
| DB | PostgreSQL 16 with `pgvector` extension |
| DB driver | `pg` (pool, max 20) |
| Migrations | `node-pg-migrate` (programmatic) |
| Auth | JWT (HS256) signed locally + DB-backed `user_sessions` table |
| Secrets | argon2id (current), bcrypt (legacy / rehash on next login) |
| AI | Provider-agnostic via `src/utils/ai.js` → OpenAI SDK shape |
| STT | OpenAI Whisper API / Google Gemini / AWS Transcribe / local whisper.cpp / browser Whisper (transformers.js) |
| TTS | Google Cloud TTS / ElevenLabs / LiteLLM-routed |
| Frontend | **Vanilla JS, no framework, no bundler.** Plain `<script defer>` tags + IIFE modules + lazy HTML components |
| Mobile | Capacitor 6 wrapper (Android shipping, iOS scaffolded) |
| Container | Docker + docker-compose |
### How it diverges from a "modern" stack
This repo deliberately rejects the React/Vite/Tailwind default. The frontend
is **38 `<script defer>` tags** in `public/index.html` that load IIFE
modules into `window` globals; the only ES-module pocket is
`public/js/bedside/` (one `<script type="module">` line). There is no
build step. There is no Webpack, no Vite, no Babel, no Tailwind, no
JSX, no TypeScript on the frontend. Every JS file is what hits the
browser.
The trade-off is intentional:
- **No build = no bundler-induced bugs**, no CI pipeline for the
frontend, no source-maps to wrangle, and a smaller image (no
`node_modules` for the browser).
- **Every change is one Ctrl-S away from being deployed** — refresh the
browser and you see it. The cache-bust at `server.js:155204`
injects `?v=BUILD_ID` so the browser always pulls the latest JS/CSS.
- **The cost is a real one**: no dependency graph, no tree-shaking, no
type checking. Cross-file references are `window.x = y` plus
copy-paste discipline. Renaming a button id and forgetting to grep
produces a silent dead handler. The static reference linter at
`scripts/lint-references.js` catches the easiest version of that bug
but cannot model control flow. See section 16.
There is a planned migration to Node + TypeScript + Fastify on the
backend, and React + TS + Vite + Tailwind + shadcn on the frontend
(`projects/migration-checkpoint`). This document describes the **current
state** as of v6.53.x.
---
## 2. Frontend architecture: the IIFE pattern
### The pattern
Every file under `public/js/` (except the bedside pocket) wraps its body
in a self-invoking function:
```js
(function () {
// private state, helpers
function doThing() { ... }
// surface public API on window
window.doThing = doThing;
})();
```
There are no `import` / `export` statements. Files communicate through:
1. **`window.X` globals.** A producer file does `window.callAI = ...` and
a consumer file later in script order calls `window.callAI(...)`.
2. **`document.dispatchEvent(new CustomEvent('tabChanged', ...))`** —
the only system-wide event bus. See `public/js/app.js:78` for the
producer; every per-tab module subscribes (e.g. `app.js:156` for
the settings tab).
3. **`localStorage` / `UIState` (section 6).** Cross-reload state.
4. **`BroadcastChannel('pedscribe-auth')`** — cross-tab logout. See
`public/js/authFetch.js:22`.
### Why this pattern was chosen
- **Zero build dependency.** The team is one pediatrician. He doesn't
want to debug `vite dev` at 11pm before clinic.
- **`<script defer>` is enough.** Defer guarantees in-order execution
after the document parses. As long as `app.js` precedes per-tab
files, every consumer sees its producers' globals.
- **Each file is an island.** A bug in `peGuide.js` cannot crash
`encounters.js` because they don't share a module graph. The IIFE
wrapper means a top-level `var` doesn't leak.
- **Easy to grep.** Search for `window.X` or `id="X"` and you find every
reference.
### The cost
- **No dependency graph.** Removing a function from one file requires
manual `grep -r 'window.functionName'` to know who calls it.
- **No tree-shaking.** The whole file ships even if you use one helper.
- **Manual event delegation against `document`.** Most click handlers
are written as `document.addEventListener('click', function(e) { if
(e.target.closest('#btn-foo')) ... })` so the handler keeps working
after lazy-loaded HTML drops `#btn-foo` into the DOM later. See
`public/js/app.js:109116` for the pattern.
- **The classic bug.** Rename a button id in `components/foo.html`
and forget to update `public/js/foo.js` — the handler silently
no-ops. The lightbox bug for Bedside pathway images was exactly
this (the id moved between components and the handler stopped
finding it). The linter at `scripts/lint-references.js` was added
in response. See section 16.
### Conventions you'll see throughout `public/js/`
- Top-of-file comment block with `============================================================`.
- Helpers are file-private; only the few functions other files call get
a `window.X = X` line at the bottom.
- Every fetch call uses `getAuthHeaders()` (`public/js/auth.js:260`) —
on web, this is just `Content-Type: application/json` and the cookie
rides automatically. On native (Capacitor), it adds the
`Authorization: Bearer <jwt>` header from secure storage. See
section 7.
- Any DOM mutation that depends on lazy-loaded markup is gated on the
`tabChanged` event; otherwise the per-tab module would query an empty
`<section>` placeholder.
---
## 3. Frontend lifecycle
### Page-load order
1. Browser fetches `/`.
2. `server.js:198205` hands back `index.html` with `?v=BUILD_ID`
appended to every local `/js/*.js` and `/css/*.css` reference. The
build id is a 7-char git short SHA when running from a checkout
(`server.js:156170`), or a fresh random hex otherwise.
3. Browser parses the HTML. Each `<script defer>` is queued.
4. Once the document is parsed, deferred scripts execute **in document
order** (this is the key contract — deferred scripts preserve
ordering, async scripts do not).
5. The global error trap installs first (`public/js/app.js:624`) so
later boot errors get reported via `navigator.sendBeacon` to
`/api/logs/client-error`.
6. `DOMContentLoaded` fires; `app.js:26` registers the tab system.
7. The first tab (`<section class="tab-content active" data-component=
"encounter">` per `public/index.html`) is loaded eagerly via
`loadComponent` at `public/js/app.js:6264`. That fetches
`/components/encounter.html`, dumps it into the section's
`innerHTML`, sets `tabEl.dataset.loaded = '1'`, dispatches
`tabChanged` with `detail.tab = 'encounter'`.
8. Per-tab modules listen for that event and initialize themselves
only when their tab activates. This keeps cold-start cost down —
the bedside calculator code does not pre-build pathway DOM if you
never click Bedside.
### `loadComponent` walkthrough — `public/js/app.js:3260`
```js
function loadComponent(tabEl) {
var component = tabEl.getAttribute('data-component');
if (!component || tabEl.dataset.loaded) return Promise.resolve();
if (_componentCache[component]) {
tabEl.innerHTML = _componentCache[component];
tabEl.dataset.loaded = '1';
return Promise.resolve();
}
if (_componentLoading[component]) return _componentLoading[component];
_componentLoading[component] = fetch('/components/' + component + '.html')
.then(function (r) { ...; return r.text(); })
.then(function (html) {
_componentCache[component] = html;
tabEl.innerHTML = html;
tabEl.dataset.loaded = '1';
// Re-attach per-tab model selectors
tabEl.querySelectorAll('.tab-model-select').forEach(function (sel) {
if (typeof window._buildModelOptions === 'function') window._buildModelOptions(sel);
});
delete _componentLoading[component];
})
.catch(...);
}
```
Three things matter:
- **In-flight de-dup.** `_componentLoading[component]` returns the same
promise to two near-simultaneous calls — important if the user
rapidly clicks two tabs.
- **Component cache is in-memory only.** A page reload clears it,
which is why `ui-state.js` exists (section 6) — the per-tab DOM
state needs to survive the reset.
- **Side effect on insert.** After the HTML is in the DOM, we hunt for
`.tab-model-select` and (re)populate it from `window._currentModels`
(set by `app.js:290304`'s call to `/api/models`).
### `activateTab` — `public/js/app.js:6791`
```js
function activateTab(tabName) {
var btn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
if (!btn || btn.classList.contains('hidden')) return false;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
var tabEl = document.getElementById(tabName + '-tab');
if (tabEl) {
tabEl.classList.add('active');
loadComponent(tabEl).then(function () {
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
});
} else {
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
}
try { localStorage.setItem('ped_last_tab', tabName); } catch (e) {}
...
}
```
Notes:
- `activateTab` is exposed at `window.activateTab = activateTab` so
`auth.js` can restore the last tab on login.
- `ped_last_tab` in localStorage drives that restore. It is
unnamespaced (no `ped_ui/` prefix) for backwards compat — Daniel's
installation has rows from before `ui-state.js` existed.
- The `tabChanged` event always fires **after** the component HTML is
in the DOM. Subscribers can therefore safely `getElementById` on
ids that live inside the lazy-loaded fragment.
### `tabChanged` consumer pattern
Every per-tab module looks like this:
```js
document.addEventListener('tabChanged', function (e) {
if (!e.detail || e.detail.tab !== 'mytab') return;
// bootstrap UI, fetch initial data, restore UIState picks
});
```
Some modules also listen for events fired immediately on script load
(non-tab-bound features like the auth fetch interceptor). The
distinction: anything that touches lazy-loaded DOM **must** wait for
`tabChanged`.
---
## 4. The bedside ES-module exception
`public/js/bedside/index.js` is the **only** file in the codebase loaded
as a real ES module:
```html
<!-- public/index.html -->
<script type="module" src="/js/bedside/index.js"></script>
```
Inside, it uses real `import`:
```js
// public/js/bedside/index.js
import './shared.js'; // side-effect: sets window._EM for back-compat
import * as ageWeight from './age-weight.js';
import * as subNav from './sub-nav.js';
// ... ~18 modules
[ ageWeight, subNav, ..., sutures ].forEach(function (m) {
if (m && typeof m.init === 'function') m.init();
});
```
This pocket exists because:
- Bedside has 20 sibling files (one per pathway: airway, sepsis, NRP,
etc.). Loading them all as IIFEs would mean 20 more `<script defer>`
lines plus careful ordering; an ES module graph is genuinely cleaner
here.
- The 20 modules expose a `init()` function that gets called once at
load. Each module knows how to lazy-attach to its DOM section.
- **`shared.js` still sets `window._EM` for back-compat** — older
bedside code that hasn't been ported looks for the global, so the
ESM module emits the global as a side effect during import.
This is the **migration target shape** for the rest of the frontend
under Daniel's revised plan: keep vanilla JS but switch IIFE → ESM,
add TypeScript, retain plain DOM (no React for now).
The CSP allows it: `scriptSrc` includes `'self'` plus the ES module
loader doesn't need any new directive.
---
## 5. Tab list + sidebar
The sidebar lives inside `public/index.html` and is **not lazy-loaded**.
Every `.tab-btn[data-tab="X"]` has a paired
`<section id="X-tab" data-component="X" class="tab-content">`. The data
binding from button → section is purely by string suffix:
`activateTab('foo')` looks for `#foo-tab`.
### Current tab roster
From `public/index.html`:
| Sidebar button (`data-tab`) | Section id | `data-component` | Component HTML | Notes |
|-----------------------------|---------------|-------------------|---------------------------|-------|
| `encounter` | `#encounter-tab` | `encounter` | `components/encounter.html` | Default active tab |
| `dictation` | `#dictation-tab` | `dictation` | `components/dictation.html` | Voice → AI note |
| `ed` | `#ed-tab` | `ed-encounter` | `components/ed-encounter.html` | ED encounter form |
| `hospital` | `#hospital-tab` | `hospital` | `components/hospital.html` | Hospital course |
| `chart` | `#chart-tab` | `chart` | `components/chart.html` | Chart review |
| `soap` | `#soap-tab` | `soap` | `components/soap.html` | SOAP note |
| `wellvisit` | `#wellvisit-tab` | `wellvisit` | `components/wellvisit.html` | Well visit |
| `sickvisit` | `#sickvisit-tab` | `sickvisit` | `components/sickvisit.html` | Sick visit |
| `vaxschedule` | `#vaxschedule-tab` | `vaxschedule` | `components/vaxschedule.html` | AAP schedule |
| `catchup` | `#catchup-tab` | `catchup` | `components/catchup.html` | Catch-up immunization |
| `peguide` | `#peguide-tab` | `pe-guide` | `components/pe-guide.html` | Physical exam guide (with audio) |
| `bedside` | `#bedside-tab` | `bedside` | `components/bedside.html` | Bedside pathways (ES-module pocket) |
| `calculators` | `#calculators-tab` | `calculators` | `components/calculators.html` | Pediatric calculators |
| `extensions` | `#extensions-tab` | `extensions` | `components/extensions.html` | Phone extensions / pagers |
| `notes` | `#notes-tab` | `notes` | `components/notes.html` | Personal notes (rich text) |
| `learning` | `#learning-tab` | `learning` | `components/learning.html` | Learning Hub (CMS-driven) |
| `cms` (`hidden` until role) | `#cms-tab` | `cms` | `components/cms.html` | Learning CMS (moderator/admin) |
| `settings` | `#settings-tab` | `settings` | `components/settings.html` | User settings |
| `faq` | `#faq-tab` | `faq` | `components/faq.html` | FAQ accordion (wired in `app.js:167182`) |
| `admin` (`hidden` until role) | `#admin-tab` | `admin` | `components/admin.html` | Admin panel |
A few sections (`encounter`, `dictation` etc.) do not have inline
markup in `index.html` — they're empty placeholders and rely entirely
on the lazy fetch. Some legacy pieces have inline markup and will
load instantly without a fetch. This isn't documented anywhere; both
patterns coexist.
### Sidebar behavior
- The whole left column (`#sidebar`) collapses on desktop (state
persisted to `localStorage['ped_sidebar_collapsed']`
`app.js:124149`).
- On mobile (`window.innerWidth <= 768`), tab clicks auto-close the
sidebar (`app.js:8689`).
- `#btn-menu-toggle`, `#btn-sidebar-close`, `#sidebar-overlay` are
delegated through a single document-level click listener in
`app.js:109116`. This is the canonical pattern — never attach
click handlers directly to lazy-loaded elements; delegate from
document.
### Hidden tabs
`cms` and `admin` start with `class="hidden"`. After login, `auth.js`
checks `req.user.role` (returned by `/api/auth/me`) and toggles the
`hidden` class so the appropriate buttons appear.
---
## 6. `ui-state.js` — sub-pill persistence
### Why this exists
`_componentCache` (in `app.js:29`) is in-memory and dies on every page
reload. So does the live DOM. If a user picks "Cardiac" sub-section
inside Bedside, then reloads, both the inserted HTML and any
in-DOM "active" classes are gone. Vanilla `localStorage` works, but
without a namespace it's hard to enumerate, and every consumer has to
hand-roll the try/catch (Safari private mode throws on
`localStorage.setItem`).
`public/js/ui-state.js` is the namespaced shim:
```js
(function () {
var PREFIX = 'ped_ui/';
function get(key) { try { return localStorage.getItem(PREFIX + key); } catch (e) { return null; } }
function set(key, value) { try { localStorage.setItem(PREFIX + key, value); } catch (e) {} }
function del(key) { try { localStorage.removeItem(PREFIX + key); } catch (e) {} }
window.UIState = { get: get, set: set, del: del };
})();
```
### Consumers (current grep)
- `public/js/calculators.js`
- `public/js/wellVisit.js`
- `public/js/peGuide.js`
- `public/js/bedside/sub-nav.js`
Every key lives under `ped_ui/`. Examples in the wild:
`ped_ui/peGuide.lastSection`, `ped_ui/bedside.subTab`,
`ped_ui/wv.activePill`. The convention is `module.dotted.path` after
the prefix.
### Why not just dump everything into one JSON blob
Multiple tabs writing to one shared blob would race. Per-key entries
are atomic from each consumer's perspective.
### Versus `ped_last_tab`
`ped_last_tab` (set in `app.js:84`) predates `UIState` and is an
unnamespaced top-level key. It is the **macro** state — which tab to
show. `UIState` keys are the **micro** state — which sub-pill, which
sub-tab, which collapsed/expanded section.
---
## 7. `getAuthHeaders`, `authFetch`, `secureStorage`
The frontend has three pieces of auth wiring:
### `getAuthHeaders()` — `public/js/auth.js:260273`
```js
window.getAuthHeaders = function () {
if (!isNativeApp()) {
return { 'Content-Type': 'application/json' };
}
var token = window.AUTH_TOKEN || window.SecureStorage.getSync(TOKEN_KEY) || '';
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
};
};
```
- **Web browser path.** Returns just the content-type header. The
`ped_auth` httpOnly cookie rides automatically because all fetches
are same-origin and `fetch()`'s default `credentials = same-origin`
sends them.
- **Native (Capacitor) path.** Reads the JWT out of secure storage and
attaches it as `Authorization: Bearer …`. There is no cookie on
native — the `ped_auth` cookie is web-only.
The server's `authMiddleware` (`src/middleware/auth.js:2531`) reads the
Bearer header first, then falls back to the cookie:
```js
var authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7) || null;
}
if (!token && req.cookies && req.cookies.ped_auth) {
token = req.cookies.ped_auth;
}
```
This means `getAuthHeaders()` works identically on both clients —
the caller doesn't need to know which platform it's on.
### `authFetch.js` — global 401 handler
`public/js/authFetch.js` monkey-patches `window.fetch`. On any 401 from
an `/api/*` endpoint that isn't an auth endpoint, it:
1. Clears `window.AUTH_TOKEN` and the SecureStorage / localStorage copies.
2. Shows a toast.
3. Reloads after 800ms — the boot flow then hits `/api/auth/me`, gets
another 401 (no cookie / no token), and shows the login screen.
It also opens a `BroadcastChannel('pedscribe-auth')`. When any tab
posts `{ type: 'logout' }` on it, every other open tab receives the
message and runs the same logout flow. This solves the
shared-workstation case — log out in one tab, every other open tab
drops PHI within milliseconds instead of waiting for its next failed
fetch.
The interceptor self-installs once via `window.__fetchAuthIntercepted`
guard so it survives a re-import / re-load script (re-injection
during dev).
### `secureStorage.js` — Capacitor wrapper
A shim around two backends:
- **Web.** Plain `localStorage` (synchronous reads via `getSync`).
- **Native.** `capacitor-secure-storage-plugin` → iOS Keychain or
Android EncryptedSharedPreferences. Async reads via `get`, plus a
synchronous `getSync` that returns from a memory cache populated by
`hydrate(keys)` at boot.
The keys it stores: `ped_scribe_token` (the JWT), `ped_scribe_user`
(JSON cache of the current user), `ped_session_id`. None are stored
on web — the cookie is the source of truth for the JWT, and user
info is pulled from `/api/auth/me`.
---
## 8. Backend architecture: `server.js` as composition root
`server.js` is the only file that wires the application together. Read
it top-to-bottom and you have the whole boot sequence.
### Boot order (line by line)
1. **`server.js:17`** — `require('dotenv').config()` reads `.env`
first thing. Then express, cors, helmet, rate-limit, cookie-parser.
2. **`server.js:10`** — `loggingMiddleware` is required (initializing
the `auditQueue`s as a side effect).
3. **`server.js:1213`** — Express app + raw HTTP server (the raw
server is kept so SIGTERM can call `server.close` directly).
4. **`server.js:16`** — `app.set('trust proxy', 1)`. Critical: the
reverse proxy strips X-Forwarded-For; trust the first hop so
`req.ip` is the real client IP for rate-limit + audit log.
5. **`server.js:2154`** — Helmet with a hand-crafted CSP. CSP allows:
- `'self'` everywhere by default.
- `'wasm-unsafe-eval'` and `'unsafe-eval'` because in-browser
Whisper (`@xenova/transformers`) needs both.
- `cdn.jsdelivr.net` for the transformers worker.
- `huggingface.co` + the cdn-lfs subdomains for Whisper model
downloads.
- `https://challenges.cloudflare.com` for Turnstile.
- `connectSrc` extended to `clinicaltables.nlm.nih.gov` for ICD
autocomplete.
- `upgradeInsecureRequests: null` so the e2e container can be
hit over plain HTTP. Production has Caddy in front, mixed
content is a non-issue.
6. **`server.js:5980`** — CORS, scoped to `/api/*` only. Static
assets must not be CORS-checked because ES-module scripts always
send an Origin header. In production, fail-closed: if neither
`APP_URL` nor `CORS_ORIGINS` are set, the process refuses to
start (`server.js:6366`). In dev (no `APP_URL`), all origins
are allowed.
7. **`server.js:82`** — `cookie-parser`.
8. **`server.js:88`** — `express.json({ limit: '10mb' })`. The 10 MB
cap exists because chart-review payloads with many notes can
exceed Express's 100 KB default. Audio uploads use multipart and
bypass this cap (multer in `transcribe.js` enforces 25 MB).
9. **`server.js:95137`** — Rate limiters, layered:
- **Global `/api/`** — 200 req/min default, overridable via
`API_RATE_LIMIT_MAX` (the e2e container raises to 5000).
- **`/api/auth/login`** — 10 attempts / 15 min, overridable via
`LOGIN_RATE_LIMIT_MAX` (e2e raises to 500 for multi-worker
Playwright).
- **`/api/auth/register`** — 5 / hour.
- **`/api/auth/forgot-password`** — 5 / hour.
- **`/api/auth/resend-verification`** — 3 / 15 min.
- **A "sensitiveAuthLimiter"** (20 / 15 min) on
`change-password`, `setup-2fa`, `verify-2fa`, `disable-2fa`.
This blocks brute-forcing TOTP via a stolen cookie; the 10⁶
space is way out of reach at 20/15min.
10. **`server.js:140144`** — `/.well-known/assetlinks.json` for TWA
(Trusted Web Activity / Android app verification). Must come
**before** the static handler.
11. **`server.js:153196`** — Build-id cache busting:
- Compute `BUILD_ID` from `.git/HEAD` (or `BUILD_ID` file in the
Docker image, or random hex).
- `getTemplatedIndex()` reads `public/index.html`, regex-replaces
every local `/js/...js` and `/css/...css` reference to append
`?v=BUILD_ID`. The mtime check makes this essentially free
after the first read.
12. **`server.js:198205`** — `GET /` and `GET /index.html` serve the
templated HTML. `Cache-Control: no-cache, no-store,
must-revalidate` so browsers always re-fetch (and pick up the
new BUILD_ID, which then busts the JS/CSS cache).
13. **`server.js:208`** — `GET /api/build` returns the build id —
useful for "what version is the prod box running" debugging.
14. **`server.js:210`** — `loggingMiddleware`. Wraps `res.json` to
emit `apiCall` log lines for every non-GET `/api/*` call.
15. **`server.js:211226`** — `express.static(public/)` with
per-file Cache-Control:
- `.html``no-cache, no-store, must-revalidate`.
- `.js` / `.css``public, max-age=3600` (1 hour) — the
`?v=BUILD_ID` query param forces a refetch on deploy.
- `/components/*` → 1 hour.
16. **`server.js:229303`** — Route mounts. Order matters:
- **Auth routers first.** `/api/auth/*` from `auth.js` and `oidc.js`.
- **Learning Hub admin BEFORE general `/api/admin`** — the
`learningAdmin` router uses `moderatorMiddleware` (lower
privilege than `adminMiddleware`); mounting it after
`/api/admin` would attach the strict adminMiddleware first
and block moderators.
- **General admin routers** (`admin.js`, `adminConfig.js`,
`adminMilestones.js`).
- **Public endpoints** (`/api/models`, `/api/health`,
`/api/health/detailed`) — explicitly defined so no later
`authMiddleware` mount catches them.
- **Authenticated feature routers** (28+) — every one applies
`authMiddleware` internally via `router.use(authMiddleware)`.
17. **`server.js:317322`** — Fallback `GET /` and 404 handler.
18. **`server.js:329332`** — 3-second deferred `PROMPTS.loadFromDb(db)`
call. Lets the DB pool come up first; loads any admin-edited
prompt overrides from `app_settings` rows with key prefix `prompt.`.
19. **`server.js:334343`** — Listen on `process.env.PORT || 3000`.
Print the banner.
20. **`server.js:345387`** — SIGTERM / SIGINT graceful shutdown:
- Stop accepting new connections (`server.close`).
- Drain `auditQueue.drainAll()` — flushes any pending audit /
api / access log batches (`src/utils/auditQueue.js`).
- **Clear `db._cleanupInterval`** explicitly — the hourly
cleanup timer would otherwise keep the event loop alive past
`process.exit(0)` and Docker's 10-second SIGKILL deadline.
- `pool.end()` to drain Postgres.
- 9-second hard deadline to beat Docker's SIGKILL.
### Why this is a "composition root" pattern
Every dependency is wired here. There is no DI container, no service
locator. To know what middleware runs, look at `server.js`. To know
what routes exist, look at `server.js`. To know the rate-limit policy,
look at `server.js`. It's a 388-line file that tells you everything.
Compare the alternative — a magic auto-loader that scans `src/routes/`
and mounts everything under `/api/`. That would be tighter, but the
mount order matters in two specific cases (learning admin vs admin,
and public endpoints vs authenticated routers), so explicit wiring
wins.
---
## 9. Route convention
Every file in `src/routes/` follows the same shape:
```js
// ============================================================
// FOO ROUTES — One-line description
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto'); // if it touches PHI
var { callAI } = require('../utils/ai'); // if it does AI work
router.use(authMiddleware); // protect everything below
router.get('/foo/:id', async function (req, res) {
try {
// 1. Validate input
if (!req.params.id) return res.status(400).json({ error: 'id required' });
// 2. Fetch user-scoped data
var row = await db.get(
'SELECT * FROM foo WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' });
// 3. Decrypt as needed
try { row.body = cryptoUtil.decryptString(row.body); } catch (e) {}
// 4. Optionally call AI
// var result = await callAI(prompt, model);
// 5. Respond
res.json({ success: true, foo: row });
// 6. Audit (after the response — the queue absorbs the cost)
logger.audit(req.user.id, 'foo_load', 'Loaded foo:' + row.id, req, { category: 'clinical' });
} catch (e) {
logger.error('GET /foo/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;
```
Six fixed beats: **validate → fetch user-scoped → decrypt → AI →
respond → audit**. The rare router that needs admin gates a sub-route
with `adminMiddleware` after `authMiddleware`.
### One-line purpose for every router
From `server.js:229303`:
| File | Mount path | Purpose |
| ------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- |
| `routes/auth.js` | `/api/auth` | Local + email/password login, register, verify, reset, 2FA setup/verify/disable, change-password, `/me` |
| `routes/oidc.js` | `/api/auth` | OIDC SSO start/callback, IdP-linked account creation |
| `routes/learningAdmin.js` | `/api/admin/learning` | Learning Hub CMS — moderators + admins; categories, content, questions |
| `routes/admin.js` | `/api/admin` | User mgmt: list, verify, disable, delete, promote |
| `routes/adminConfig.js` | `/api/admin` | `app_settings` editor: SMTP, OIDC, models, prompts, announcements, feature flags |
| `routes/adminMilestones.js` | `/api/admin` | Editor for `developmental_milestones` reference table |
| `routes/learningHub.js` | `/api/learning` | User-facing Learning Hub: list categories/content, take quizzes, record progress |
| `routes/transcribe.js` | `/api` | `/api/transcribe` (multipart audio → text) and `/api/transcribe/status` |
| `routes/hpi.js` | `/api` | HPI generation from transcript |
| `routes/hospitalCourse.js` | `/api` | Hospital course note generation |
| `routes/chartReview.js` | `/api` | Chart review summarization (multi-note input) |
| `routes/milestones.js` | `/api` | Read milestones, run developmental check |
| `routes/peGuide.js` | `/api` | PE guide AI prompts (audio findings → narrative) |
| `routes/extensions.js` | `/api` | CRUD on `user_phone_extensions` |
| `routes/soap.js` | `/api` | SOAP note generation |
| `routes/tts.js` | `/api` | Text-to-speech proxy (Google / ElevenLabs / LiteLLM) |
| `routes/nextcloud.js` | `/api` | WebDAV upload of finished notes |
| `routes/refine.js` | `/api` | Take a draft note + correction prompt, return refined note |
| `routes/logs.js` | `/api` | Client-side error log ingest (`/api/logs/client-error`) |
| `routes/encounters.js` | `/api` | **Sacred.** Save / load / list / delete `saved_encounters`. Optimistic lock + idempotency. See section 13 |
| `routes/memories.js` | `/api` | CRUD on `user_memories` (style hints, templates) |
| `routes/notes.js` | `/api` | CRUD on `personal_notes` + voice-to-AI-note + trash/restore |
| `routes/documents.js` | `/api` | S3 doc upload / list / download / delete (metadata in `user_documents`) |
| `routes/audioBackups.js` | `/api` | Server-side audio retry store (encrypted gzip, 24h TTL) |
| `routes/billing.js` | `/api` | Encounter coding suggestions (E&M codes) |
| `routes/sessions.js` | `/api/sessions` | Active sessions list + admin-revoke |
| `routes/wellVisit.js` | `/api` | Well-visit AI note pipeline |
| `routes/sickVisit.js` | `/api` | Sick-visit AI note pipeline |
| `routes/edEncounters.js` | `/api` | ED encounter pipeline |
| `routes/dontMiss.js` | `/api` | "Don't miss" differential / red-flag prompt |
| `routes/userPreferences.js` | `/api/user` | User-level preferences (STT model, TTS voice, etc.) |
| `routes/learningAI.js` | `/api/admin/learning` | AI-assisted Learning Hub authoring (generate quiz from content, embed) |
**32 routers** plus inline routes for `/api/health`, `/api/health/detailed`,
`/api/models`, `/api/build`, `/api/user/webdav-path`. Every authenticated
router calls `router.use(authMiddleware)` at the top — there is no global
`app.use(authMiddleware)` for `/api`. Always-on auth would clobber
`/api/health`, `/api/models`, the OIDC callbacks, and the public auth
endpoints.
---
## 10. Database wrapper (`src/db/database.js`)
A thin promise-returning shim over `pg.Pool` that mimics the SQLite
`better-sqlite3` API used in earlier versions of this app. The shape:
```js
var db = {
get: async function (sql, params) { ... return row || null; },
all: async function (sql, params) { ... return rows; },
run: async function (sql, params) {
// Auto-appends RETURNING id on INSERTs without one
// Returns { lastInsertRowid, changes }
},
query: async function (sql, params) { ... return pool.result; },
getSetting: async function (key) { ... },
setSetting: async function (key, value) { ... },
pool: pool,
_cleanupInterval: <Timeout>
};
```
### Placeholder normalization — `database.js:512518`
```js
function convertPlaceholders(sql) {
var index = 0;
return sql.replace(/\?/g, function () { index++; return '$' + index; });
}
```
The wrapper accepts `?` SQLite-style placeholders **and** `$1`/`$2`
Postgres-style. It rewrites `?``$N` before passing to `pg`. This
is residual from the SQLite-era API and lets old route code keep
working unchanged.
### `db.run` quirk — auto-RETURNING
```js
if (sql.trim().toUpperCase().startsWith('INSERT') &&
pgSql.toUpperCase().indexOf('RETURNING') === -1) {
pgSql += ' RETURNING id';
}
```
INSERT statements without RETURNING get one appended so
`.lastInsertRowid` is populated. Tables without an `id` column will
break here — every table in the schema does have one, so this is
fine in practice.
### Boot — `database.js:439450`
On require, the module:
1. Logs "✅ PostgreSQL: connected" or fails loudly.
2. Runs `initDatabase()` (the **idempotent baseline**).
3. Runs `node-pg-migrate` via `runMigrations()` from `migrate.js`
the **versioned delta**.
`initDatabase()` does:
- `CREATE EXTENSION IF NOT EXISTS vector` — pgvector for embeddings.
- **Collation drift check.** Compare `pg_database.datcollversion`
against `pg_database_collation_actual_version()`. On mismatch
(i.e., the libc / ICU version in the postgres image changed),
`REINDEX DATABASE` + `ALTER DATABASE ... REFRESH COLLATION
VERSION`. This prevents silent index corruption — the symptom
would be "user can't log in despite correct password" because
the email index gives wrong rows.
- **`COLLATE "C"` migration.** One-time DROP/CREATE of
`idx_users_email` and `idx_sessions_token_hash` with `COLLATE "C"`,
gated by an `app_settings` row `migration.text_indexes_c`. C
collation is byte-exact and immune to ICU library upgrades. This
protects the auth-critical lookup paths.
- `CREATE TABLE IF NOT EXISTS` for the baseline schema (users,
app_settings, audit_log, api_log, access_log, saved_encounters,
user_memories, user_phone_extensions, learning_*, see section 11).
- A pile of `ALTER TABLE ADD COLUMN IF NOT EXISTS` migrations for
upgrades from older versions (`role`, `disabled`, `oidc_sub`,
`idempotency_key`, etc.).
- IVFFLAT index on `learning_content.embedding` once ≥ 10 embeddings
exist (lists = sqrt(rows)).
- Seeds default `app_settings` rows via `INSERT ... ON CONFLICT DO
NOTHING` (registration_enabled, announcement defaults, feature
flags, etc.).
### `_cleanupInterval` — `database.js:453471`
```js
async function cleanupExpired() {
await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()');
await pool.query('DELETE FROM audio_backups WHERE expires_at < NOW()');
await pool.query("DELETE FROM user_sessions WHERE created_at < NOW() - INTERVAL '7 days'");
}
var _cleanupInterval = setInterval(cleanupExpired, 60 * 60 * 1000); // hourly
setTimeout(cleanupExpired, 10000); // also at +10s
db._cleanupInterval = _cleanupInterval;
```
The interval handle is exposed because of the SIGTERM problem: Node
considers an unref'd timer enough to keep the event loop alive. Without
explicitly clearing it, the process won't exit cleanly within the
9-second graceful-shutdown window, and Docker SIGKILLs us at 10s.
`server.js:371` does `clearInterval(dbMod._cleanupInterval)` at
shutdown.
---
## 11. Schema
PostgreSQL 16 + `pgvector`. Two-layer management: **baseline** (idempotent
`CREATE TABLE IF NOT EXISTS` on every boot, in `database.js`) plus
**versioned migrations** (`node-pg-migrate`, in `migrations/`).
### `users` — local accounts + OIDC + per-user prefs
| Column | Type | Notes |
| ------ | ---- | ----- |
| `id` | SERIAL PK | |
| `email` | TEXT UNIQUE NOT NULL | Index uses `COLLATE "C"` for ICU-drift immunity |
| `password` | TEXT NOT NULL | argon2id (current) or bcrypt (rehashed on next login). For OIDC-auto-created accounts, random hex (unverifiable). |
| `name` | TEXT | |
| `role` | TEXT default `'user'` | `user` / `admin` / `moderator` |
| `email_verified` | BOOLEAN default false | |
| `verify_token`, `verify_expires` | TEXT, BIGINT | Email verification |
| `totp_secret`, `totp_enabled` | TEXT, BOOLEAN | TOTP 2FA |
| `totp_backup_codes` | TEXT | JSON array of bcrypt-hashed 10-char recovery codes; consumed atomically on login |
| `passkey_credentials` | TEXT | Reserved for future WebAuthn |
| `oidc_sub` | TEXT | IdP subject identifier (when linked) |
| `disabled` | BOOLEAN default false | Soft disable; auth middleware returns 403 |
| `nextcloud_url`, `nextcloud_user`, `nextcloud_token`, `nextcloud_folder` | TEXT | WebDAV creds. **`nextcloud_token` is encrypted** (`enc1:` prefix) |
| `reset_token`, `reset_expires` | TEXT, BIGINT | Password reset |
| `stt_model`, `tts_voice` | TEXT | Per-user STT/TTS overrides |
| `webdav_learning_path` | TEXT | Learning Hub WebDAV browser root |
| `created_at`, `updated_at` | TIMESTAMPTZ | |
**Encryption.** Only `nextcloud_token` is encrypted at rest.
Email/name/role are deliberately plaintext (needed for indexed lookups,
admin display).
**Who writes.** `routes/auth.js` (register, password change, profile),
`routes/oidc.js` (auto-create on IdP first-login), `routes/admin.js`
(promote/disable/delete), `routes/userPreferences.js` (stt_model /
tts_voice), `routes/nextcloud.js` (webdav creds).
**Who reads.** `authMiddleware` on every request (`SELECT id, email,
name, role, totp_enabled, disabled FROM users WHERE id = ?`).
### `user_sessions` — authoritative session registry
| Column | Type | Notes |
| ------ | ---- | ----- |
| `id` | TEXT PK | UUID |
| `user_id` | INTEGER FK users.id (CASCADE) | |
| `token_hash` | TEXT NOT NULL | SHA-256 of the JWT. Index uses `COLLATE "C"` |
| `ip_address`, `user_agent` | TEXT | |
| `device_label` | TEXT | Parsed UA — "Chrome on Android", "PedScribe (Android)", etc. |
| `created_at`, `last_activity` | TIMESTAMPTZ | `last_activity` updated on POST/PUT/DELETE/PATCH only, throttled to ≤1 / 10 min |
**The flow.** Login mints a JWT, hashes it (SHA-256), inserts a row.
Every subsequent request hits `authMiddleware`, which looks up the
session by token_hash. If the row is gone (logout, password change,
admin revoke), the request returns 401 even if the JWT is otherwise
valid. This is what makes JWTs revocable in this app.
**Encryption.** None — IPs and UAs are operational metadata, not PHI.
**Retention.** Sliding 24h idle timeout (web only) — `auth.js:69` checks
`now - last_activity > 24h` and deletes the row + clears the cookie.
Mobile clients have no idle check (persistent JWT in Keychain). The
hourly cleanup job sweeps anything > 7 days old created_at as a
belt-and-braces backstop.
### `saved_encounters` — draft/complete encounter workspace
| Column | Type | Notes |
| ------ | ---- | ----- |
| `id` | SERIAL PK | |
| `user_id` | INTEGER FK users.id (CASCADE) | |
| `label` | TEXT NOT NULL DEFAULT 'Untitled' | Unique per user within active rows |
| `enc_type` | TEXT NOT NULL DEFAULT 'encounter' | `encounter`, `dictation`, `soap`, `sickvisit`, `wellvisit`, `hospital`, `chart`, `milestones`, `ed`, … |
| `transcript` | TEXT | **Encrypted** (`enc1:`) |
| `generated_note` | TEXT | **Encrypted** (`enc1:`) |
| `partial_data` | TEXT | JSON of in-progress form state. **Encrypted** (`enc1:`) |
| `status` | TEXT DEFAULT 'active' | |
| `version` | INT NOT NULL DEFAULT 1 | Optimistic lock — see section 13 |
| `idempotency_key` | TEXT | Prevents duplicate creates on double-submit |
| `created_at`, `updated_at` | TIMESTAMPTZ | |
| `expires_at` | TIMESTAMPTZ | Default `NOW() + 7 days`, configurable via `site.auto_delete_days` |
**Who writes.** `routes/encounters.js` (sacred). The wellVisit /
sickVisit / ed / hospital / soap routes all funnel through the same
`POST /api/encounters/saved` endpoint when persisting.
**Retention.** Hourly cleanup deletes rows where `expires_at < NOW()`.
The 7-day default is a deliberate ephemerality choice — this is a
clinical scratchpad, not a chart. Notes that need to survive go to
Nextcloud or are exported.
### `personal_notes` — clinician scratchpad
| Column | Type | Notes |
| ------ | ---- | ----- |
| `id` | SERIAL PK | |
| `user_id` | INTEGER FK (CASCADE) | |
| `title` | TEXT NOT NULL | **Encrypted** (`enc1:`) |
| `body` | TEXT NOT NULL | **Encrypted** (`enc1:`) — Tiptap HTML |
| `created_at`, `updated_at` | TIMESTAMPTZ | |
| `deleted_at` | TIMESTAMPTZ NULL | Soft delete — NULL = active, timestamp = trashed |
Created via migration `1777003849000_add-personal-notes.js`, soft-delete
added via `1777090000000_notes-trash.js`.
**Distinct from `user_memories`.** Memories are AI prompt fuel
(templates / style hints) — they get injected into LLM calls. Notes
are pure clinician text that never goes to an AI. Both are encrypted.
### `user_memories` — AI prompt fuel
| Column | Type | Notes |
| ------ | ---- | ----- |
| `id` | SERIAL PK | |
| `user_id` | INTEGER FK (CASCADE) | |
| `category` | TEXT NOT NULL DEFAULT 'custom' | `physical_exam`, `ros`, `encounter_format`, `family_history`, `assessment_plan`, `custom`, `template_soap`, `template_hpi`, `template_wellvisit`, `template_sickvisit`, `template_ed`, legacy `correction_*` (filtered out) |
| `name` | TEXT NOT NULL | **Encrypted** |
| `content` | TEXT NOT NULL | **Encrypted** |
| `created_at`, `updated_at` | TIMESTAMPTZ | |
`routes/memories.js` filters `correction_*` rows out of the API
response — these are dead-feature artifacts from a Dragon-style
learning module that was removed. They're left in place rather than
deleted to avoid losing user content irreversibly.
### `audit_log`, `api_log`, `access_log` — three log writers
All three written via `src/utils/auditQueue.js` (batched, 1 s flush).
| Table | Purpose | Writer | Source of records |
| ----- | ------- | ------ | ----------------- |
| `audit_log` | Human-readable security/action audit | `logger.audit(...)` | Login attempts, encounter saves/loads, password change, 2FA events, admin actions, AI calls (with model + tokens) |
| `api_log` | Per-request AI telemetry (cost, tokens) | `logger.apiCall(...)` (auto-fired by `loggingMiddleware` on every non-GET `/api/*`) | All AI-bearing requests with `usage` in the response body |
| `access_log` | Auth-only event stream | `logger.access(...)` | login, logout, failed_login |
**Schema details.**
`audit_log`:
- `user_id`, `action`, `category` (`auth` / `clinical` / `integration` / `export` / `documents` / `phi_access` / `general`), `details` (PHI-redacted by `redact.js`, capped at 500 chars), `ip_address`, `user_agent`, `model_used`, `tokens_used`, `duration_ms`, `status` (`success` / `failure`).
`api_log`:
- `user_id`, `endpoint`, `method`, `status_code`, `request_size`, `response_size`, `model_used`, `tokens_input`, `tokens_output`, `cost_estimate` (USD), `duration_ms`, `ip_address`, `error`. Costs use a hardcoded rate table in `logger.js:3248` (OpenRouter would use live pricing in a future revision).
`access_log`:
- `user_id`, `action`, `ip_address`, `user_agent`, `success`.
**Encryption.** None. The redactor (`src/utils/redact.js`) strips SSN
(`\d{3}-\d{2}-\d{4}`), US phones, emails, dates of birth, long ID
runs, and aggressively truncates anything that smells like a clinical
note body (>4 newlines or >1000 chars → keep first 120 chars +
`[TRUNCATED:possible-note-body]`). The 500-char cap is the second
guardrail.
**Retention.** None — explicit. These tables grow forever. Disk
should be sized accordingly. A future migration is on the roadmap.
### `audio_backups` — failed-transcription retry store
| Column | Type | Notes |
| ------ | ---- | ----- |
| `id` | SERIAL PK | |
| `user_id` | INTEGER FK (CASCADE) | |
| `module` | TEXT default `'encounter'` | `encounter`, `dictation`, etc. |
| `mime_type` | TEXT default `'audio/webm'` | |
| `size_bytes`, `compressed_bytes` | INTEGER | |
| `audio_data` | BYTEA NOT NULL | gzip → AES-256-GCM, version-byte 0x01. Legacy 0x1F-prefixed rows (raw gzip without enc wrapper) pass through `decryptBuffer` transparently |
| `created_at`, `expires_at` | TIMESTAMPTZ | 24h default |
**Who writes.** `routes/audioBackups.js` from the frontend
`audioBackup.js` lifecycle when a transcription call fails — the
client uploads the gzipped audio so the user can retry without
re-recording.
**Retention.** 24 hours via hourly cleanup. Audio is the most sensitive
PHI in the system; short retention is intentional.
### `user_documents` — S3 doc metadata
| Column | Type | Notes |
| ------ | ---- | ----- |
| `id` | SERIAL PK | |
| `user_id` | INTEGER FK (CASCADE) | |
| `s3_key` | TEXT NOT NULL | Bucket-relative key (prefixed with user id) |
| `filename`, `mime_type` | TEXT | |
| `size_bytes` | INTEGER | |
| `description` | TEXT | |
| `created_at` | TIMESTAMPTZ | |
**File bytes live in S3** (or any S3-compatible store: MinIO, Backblaze
B2 — set `S3_FORCE_PATH_STYLE=true` for non-AWS). The DB row is
metadata only.
### `user_phone_extensions` — personal directory
| Column | Type | Notes |
| ------ | ---- | ----- |
| `id` | SERIAL PK | |
| `user_id` | INTEGER FK (CASCADE) | |
| `location` | TEXT | |
| `name`, `number` | TEXT | |
| `type` | TEXT CHECK (`extension` or `pager`) | |
| `notes` | TEXT default `''` | |
| `trashed_at` | TIMESTAMPTZ NULL | Soft delete |
| `created_at`, `updated_at` | TIMESTAMPTZ | |
Indexes split on `WHERE trashed_at IS NULL` vs `IS NOT NULL` so
active-row queries don't scan trash.
### `app_settings` — live runtime config
```sql
CREATE TABLE app_settings (
key TEXT PRIMARY KEY, -- COLLATE "C"
value TEXT NOT NULL,
updated_at TIMESTAMPTZ,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL
);
```
Read via `config.get(key, default)` (`src/utils/config.js`) with a
2-minute in-memory cache. Writes invalidate the cache. Examples of
keys: `registration_enabled`, `site.auto_delete_days`, `oidc.enabled`,
`models.default`, `prompt.<name>`, `feature.read_aloud`,
`smtp.host`, `email.verify.subject`, `migration.text_indexes_c`.
The Admin Panel can edit any of these. Prompt templates in
`src/utils/prompts.js` are loaded from the DB on boot
(`server.js:329332`) — admins can hot-edit a prompt without redeploy.
### Learning Hub tables
`learning_categories`, `learning_content`, `learning_questions`,
`learning_options`, `learning_progress`. CMS-style content tree.
`learning_content.embedding` is `VECTOR(768)` for semantic search,
indexed via IVFFLAT once ≥ 10 embeddings exist (`database.js:380399`).
Default model is `text-embedding-005` (Vertex). See
`docs/learning-hub.md` for the deep dive.
### `developmental_milestones` — AAP reference data
| Column | Type | Notes |
| ------ | ---- | ----- |
| `id` | SERIAL PK | |
| `age_group` | TEXT | `2 months`, `4 months`, `1 year`, … |
| `domain` | TEXT | `motor` / `language` / `social` / `cognitive` |
| `milestone_text` | TEXT | |
| `sort_order` | INTEGER | |
| `created_at`, `updated_at` | TIMESTAMPTZ | |
Editable from the Admin Panel via `routes/adminMilestones.js`.
Read-only from the user-facing milestones tab.
### `pgmigrations`
Created and managed by `node-pg-migrate`. Records applied migration
filenames + run time. Never edit by hand.
---
## 12. Encryption at rest
`src/utils/crypto.js`. AES-256-GCM via Node's built-in `crypto`. No
external KMS — symmetric key from a single env var.
### Key handling
```js
var raw = process.env.DATA_ENCRYPTION_KEY;
if (raw) {
if (raw.length === 64 && /^[0-9a-fA-F]+$/.test(raw)) {
KEY = Buffer.from(raw, 'hex'); // preferred: 32 raw bytes
} else if (raw.length >= 32) {
KEY = crypto.createHash('sha256').update(raw).digest(); // fallback: SHA-256 derive
console.warn('[crypto] DATA_ENCRYPTION_KEY is not 64 hex chars; derived via SHA-256.');
}
}
if (!KEY && (process.env.NODE_ENV === 'production' || process.env.APP_URL)) {
process.exit(1); // hard refuse to run in prod without a key
}
```
The fallback exists so a humans-typed passphrase still works in dev.
Production must pass a real 64-hex key (`openssl rand -hex 32`).
### Format
**Strings.** `enc1:` prefix + base64( iv(12) || authTag(16) ||
ciphertext ).
```js
function encryptString(plaintext) {
if (plaintext == null) return plaintext;
if (!KEY) return plaintext; // dev passthrough
var iv = crypto.randomBytes(12);
var cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
var ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
var tag = cipher.getAuthTag();
return PREFIX + Buffer.concat([iv, tag, ct]).toString('base64');
}
```
**Buffers.** Single 0x01 version byte prefix + iv(12) + tag(16) +
ciphertext. The 0x01 byte is the discriminator from legacy raw-gzip
buffers (which start with 0x1F, the gzip magic).
### What is encrypted
- `users.nextcloud_token`
- `saved_encounters.transcript`
- `saved_encounters.generated_note`
- `saved_encounters.partial_data`
- `personal_notes.title`
- `personal_notes.body`
- `user_memories.name`
- `user_memories.content`
- `audio_backups.audio_data` (gzip → AES-GCM)
### What is NOT encrypted
- All `users` columns except `nextcloud_token` (email is indexed; role
needs to be readable for admin).
- `audit_log.details` — but it goes through `redact.js` first and is
capped at 500 chars.
- `api_log.*`, `access_log.*` — operational telemetry only, no PHI.
- `app_settings.value`.
- `learning_content.body` — public content (admin-curated).
- `developmental_milestones.milestone_text` — reference data.
- `user_documents.*` — metadata only; the file bytes live in S3 with
whatever encryption that bucket has. Recommend SSE-S3 or KMS at the
bucket level.
### Legacy plaintext rows
Both `decryptString` and `decryptBuffer` pass through values without
the encryption prefix. This means a row written before encryption was
introduced reads back as plaintext, and a re-write encrypts it. No
explicit migration was run. Over time, rows naturally rotate to
encrypted as users edit them.
### Why GCM, not CBC
Authenticated encryption — the auth tag detects ciphertext tampering,
which a stolen-DB-but-no-key attacker might attempt to use to inject
controlled content. CBC + HMAC would work too but GCM is one
construct.
### Why one app-wide key
The threat model is "Postgres dump leaks." If the attacker has the
running container, they have the key, and there is nothing
application-layer encryption can do. KMS / per-user keys would help
the latter but not the former; the cost (KMS setup, key rotation,
multi-tenant complexity) was deemed not worth it for a single-tenant
self-hosted tool.
---
## 13. Optimistic locking + idempotency
Two related guards on `saved_encounters`, both enforced by
`routes/encounters.js`. **Sacred file — do not refactor.**
### Optimistic locking via `version`
Schema (added by `migrations/1744650000000_add-encounter-version.js`):
```js
exports.up = (pgm) => {
pgm.addColumn('saved_encounters', { version: { type: 'integer', notNull: true, default: 1 } });
};
```
Server logic (`routes/encounters.js:81105`, simplified):
```js
var existing = await db.get('SELECT id, version FROM saved_encounters WHERE id = $1 AND user_id = $2', [id, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Not found' });
var expected = req.body.expected_version;
if (expected != null && Number(expected) !== Number(existing.version || 1)) {
return res.status(409).json({ error: '...modified in another tab...' });
}
var newVersion = (Number(existing.version) || 1) + 1;
var upd = await db.run(
'UPDATE saved_encounters SET ..., version=$6, ... WHERE id=$7 AND user_id=$8 AND (version = $9 OR $9::int IS NULL)',
[..., newVersion, id, req.user.id, expected != null ? Number(expected) : null]
);
if (upd.changes === 0) return res.status(409).json({ error: 'changed under you. Reload and retry.' });
res.json({ success: true, id: id, version: newVersion });
```
Two layers:
1. **Compare-and-set in the WHERE clause** — the UPDATE only runs if
the row's version still matches the expected version. If it
doesn't, `upd.changes === 0` and we 409.
2. **Read-then-compare guard** — even before the UPDATE, we do a
SELECT and compare; this gives a cleaner 409 message with both
versions reported back to the client.
Backwards compat: if the client doesn't send `expected_version`, the
guard degrades to last-write-wins. Older mobile builds rely on this.
### Idempotency keys
Schema (in `database.js:303304`):
```sql
ALTER TABLE saved_encounters ADD COLUMN IF NOT EXISTS idempotency_key TEXT;
CREATE UNIQUE INDEX idx_saved_enc_idemp ON saved_encounters(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
```
Frontend generates a UUID and posts it with the create. If a
double-click sends two POSTs, the second one finds an existing row
with the same `(user_id, idempotency_key)` and the route returns the
existing id rather than creating a duplicate. The partial unique index
keeps the constraint from blocking nulls.
---
## 14. Migrations
`node-pg-migrate`, run programmatically from `src/db/migrate.js` after
the inline `initDatabase()` baseline finishes.
### File layout
```
migrations/
1744600000000_example-no-op.js
1744650000000_add-encounter-version.js
1777003849000_add-personal-notes.js
1777090000000_notes-trash.js
```
Filename prefix is a millisecond timestamp; node-pg-migrate sorts and
runs them in order, skipping anything in the `pgmigrations` table.
### Convention
```js
exports.up = (pgm) => {
pgm.addColumn('saved_encounters', {
version: { type: 'integer', notNull: true, default: 1 }
});
};
exports.down = (pgm) => {
pgm.dropColumn('saved_encounters', 'version');
};
```
`pgm` is the migration helper. Full API: salsita.github.io/node-pg-migrate.
Always write a `down` (even if it's a no-op `// not safely reversible`)
so rollback is possible during testing.
### Adding a new migration
1. **Create the file.** `npm run migrate:new -- name-of-thing`
creates `migrations/<timestamp>_name-of-thing.js`. (Or copy the
`1744600000000_example-no-op.js` file and rename — the script
shells out to node-pg-migrate's CLI, which does this same thing.)
2. **Write `up`.** Use `pgm.createTable / addColumn / createIndex /
dropTable / sql(...)`. For raw SQL: `pgm.sql('UPDATE ... SET ...')`.
3. **Write `down`.** Reverse the change. If genuinely irreversible
(data destruction), leave it empty with a comment.
4. **Test locally.** `docker compose -f docker-compose.local.yml up -d`
then `docker exec pediatric-ai-scribe npm run migrate:status` to
see what's applied. The boot path runs migrations automatically
(`database.js:441449`); you don't need to invoke node-pg-migrate
manually.
5. **Commit.** Migrations are part of the same commit as the code that
uses the new column.
### npm scripts
From `package.json:617`:
```
migrate → node-pg-migrate (raw CLI)
migrate:up → run all pending up
migrate:down → roll back one
migrate:status → list applied migrations + run timestamps (uses pg directly)
migrate:new → scaffold a new migration file
```
### The implicit baseline
`database.js`'s `initDatabase()` runs **on every boot**, before
migrations. It contains every table that existed before node-pg-migrate
was adopted, expressed as `CREATE TABLE IF NOT EXISTS`. This means:
- Fresh DBs get the full baseline as one big DDL block, then migrations
layer deltas on top.
- Existing DBs see all those `IF NOT EXISTS` and skip them.
- Adding a new table now: **always do it as a migration**, never edit
the inline baseline. The baseline is frozen in time as the
pre-tooling cutover snapshot.
---
## 15. Logging
Three writers, three tables, all batched via `src/utils/auditQueue.js`.
### `logger.audit(userId, action, details, req, extra)`
Writes to `audit_log`. Used for security and action events:
`login`, `login_failed`, `session_idle_timeout`, `password_changed`,
`generate_soap`, `2fa_backup_code_used`, `encounter_save`,
`encounter_load`, `webdav_upload`, `admin_user_promote`, etc.
`details` is run through `redact.js` and capped at 500 chars.
`extra` can carry `{ category, model, tokens, duration, status }`.
Also **fire-and-forget shipped to Loki** if `LOKI_URL` is set.
### `logger.apiCall(userId, endpoint, data)`
Writes to `api_log`. Auto-fired by `loggingMiddleware`
(`src/middleware/logging.js`) on every non-GET `/api/*` request — see
the `res.json` wrap at `logging.js:1341`. Records:
endpoint, method, status, sizes, model, tokens, cost estimate,
duration, IP, error.
Cost is a hardcoded rate table at `logger.js:3248` — not
authoritative, just informational. OpenRouter calls would have live
pricing in the response; a future improvement is to read from the
`usage.cost` field when present.
### `logger.access(userId, action, req, success)`
Writes to `access_log`. Auth-only stream: `login`, `logout`,
`failed_login`. Used by `routes/auth.js` and `routes/oidc.js`.
### `logger.error / logger.warn / logger.info` — file logger
Writes JSONL to `data/logs/YYYY-MM-DD.log`. PHI-redacted via
`redact.js`. Stored on the `scribe-logs` named volume so logs
survive container rebuilds.
### `auditQueue.js` batching
Each of the three tables has its own queue. Inserts buffer in memory,
flushing on either:
- 50-entry batch limit reached (`setImmediate(flush)`).
- 1-second timer interval (`setInterval`).
- Process shutdown (`drainAll()` from the SIGTERM handler).
Build the SQL with multi-row INSERT (`(?, ?, ?), (?, ?, ?), ...`) so
under load, hundreds of audit lines coalesce into one round-trip.
**Trade-off.** A process crash between flushes loses up to 1 second of
audit entries. The PostgreSQL row is the primary destination; Loki is
separately pushed fire-and-forget per call (no batching there — Loki's
own ingestion handles batching). Acceptable for an audit trail in a
single-tenant clinical tool. Not acceptable for, say, financial txn
logs — but that's not what this is.
---
## 16. Static reference linter (`scripts/lint-references.js`)
### Why it exists
In a vanilla-JS + window-globals + lazy-HTML codebase, the compiler
catches nothing. The class of bug you get is:
- Rename `<button id="btn-foo-old">` to `<button id="btn-foo-new">`
and forget to grep `public/js/foo.js`.
- The old handler `document.getElementById('btn-foo-old').addEventListener(...)`
is no longer wired up.
- Page loads fine, no error in the console — the button is just dead.
The Bedside lightbox bug was the canonical example. The
`<img id="img-lightbox">` element used to live in `calculators.html`,
loaded as part of the calculators tab. After a reorg, Bedside got its
own component but the lightbox markup didn't move. `bedside/image-lightbox.js`
kept calling `getElementById('img-lightbox')` and silently no-op'd.
### What the linter does
Reads every file under `public/js/`. Builds a set of every id
**defined anywhere** in the repo — both static HTML attributes
(`id="X"`) and dynamic JS (`element.id = "X"`). Then scans JS for
`getElementById('X')`, `querySelector('#X')`, etc. and flags
references whose id appears nowhere in the source tree.
Also scans `<img|audio|video|source|link>` `src=` and `data-img-src`
attributes — verifies the file exists on disk under `public/`.
### What it doesn't do
Does **not** model control flow. An id defined in `components/foo.html`
satisfies a JS reference even if `foo.html` never gets loaded into
the DOM at runtime. To catch that you'd need a build-time component
graph, which is exactly the framework the codebase has decided to
not adopt.
### Allowlist
`DYNAMIC_ID_PREFIXES` at `lint-references.js:64116` is the manual
allowlist for ids constructed at runtime — `em-`, `shadess-`, `wv-panel-`,
the bedside pathway prefixes (`nrp-`, `seizure-`, `sepsis-`, `airway-`,
`cardiac-`, `anaph-`, `burn-`, `vent-`, `tox-`, `trauma-`, `neo-`,
`resp-`, `sed-`, `agit-`, `antiemetic-`, `antibio-`), the calculator
prefixes (`bili-`, `bmi-`, `bp-`, `growth-`, `dose-`, `bsa-`, `equip-`,
`resus-`, `gcs-`, `vitals-`), the auth UI prefixes (`totp-`, `2fa-`,
`fpa-`, `rsp-`), etc.
When you add a new feature that builds ids dynamically, you'll likely
need a one-line addition here. Keep prefixes specific (ending in `-`)
to avoid swallowing real bugs.
### Running it
```
node scripts/lint-references.js
```
Exit code is non-zero on any unresolved reference or broken asset —
suitable for CI. Currently invoked manually as part of pre-release
discipline, not yet wired into a hook.
---
## 17. Deployment
### Dockerfile — `Dockerfile`
Two-stage build. The first stage pulls the **OpenBao CLI binary** out of
`openbao/openbao:2.5.3` (see section 18). The second stage is the
runtime image:
```Dockerfile
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg curl jq
COPY --from=bao-src /bin/bao /usr/local/bin/bao
COPY package.json ./
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& npm install --omit=dev \
&& apk del .build-deps
COPY . .
RUN chmod +x /app/docker-entrypoint.sh
RUN mkdir -p /app/data/logs
RUN mkdir -p /app/public/models/Xenova/whisper-tiny.en/onnx && \
cd /app/public/models && \
curl -sL -o transformers.min.js https://cdn.jsdelivr.net/npm/@xenova/transformers@2.0.0/dist/transformers.min.js && \
cd Xenova/whisper-tiny.en && \
curl -sL -o config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json && \
...
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "server.js"]
```
Notes:
- **`ffmpeg`** is needed to convert WebM (browser MediaRecorder output)
to PCM for AWS Transcribe.
- **`curl`, `jq`** support the OpenBao secret-fetch step (jq) and
miscellaneous downloads (curl).
- **`python3 make g++` only at build time** — needed by argon2's
node-gyp build, then dropped via the `.build-deps` virtual package.
Final image stays slim (~220 MB).
- **`npm install --omit=dev`** — devDependencies (jsdom, dompurify
for tests) are not in the runtime image.
- **Browser Whisper bundle.** Pulls the @xenova/transformers worker
build + the whisper-tiny.en model files at image-build time so
the running container has zero external dependencies for browser-
side transcription. ~42 MB of model files baked in.
- **Healthcheck.** Polls `/api/health` every 30 s. Container marked
unhealthy after 5 failures.
### docker-compose files
Three compose files, layered:
1. **`docker-compose.yml`** — production base. Pediatric-scribe on
`127.0.0.1:3552`, postgres internal-only, named volumes
`pgdata` + `scribe-logs`, app depends on postgres healthcheck.
2. **`docker-compose.local.yml`** — dev variant. Binds 3552 publicly
(no `127.0.0.1` restriction), uses `postgres:16-alpine` (no
pgvector — for fast iteration without semantic search).
3. **`docker-compose.e2e.yml`** — Playwright test container on
`127.0.0.1:3553`, sharing the same postgres + pgdata volume.
Disables Turnstile (`TURNSTILE_SECRET_KEY=""`,
`TURNSTILE_SITE_KEY=""`), disables SMTP (`SMTP_HOST=""` so register
auto-verifies and returns a session), raises rate limits
(`LOGIN_RATE_LIMIT_MAX=500`, `API_RATE_LIMIT_MAX=5000`), and
widens `CORS_ORIGINS` to include the test hostnames.
4. **`docker-compose.monitoring.yml`** — Loki + Grafana stack
(section 19). Overrides the app container to add
`LOKI_URL=http://loki:3100`.
Layered usage:
```bash
# Production
docker compose up -d
# Dev (no localhost-restricted port)
docker compose -f docker-compose.local.yml up -d
# Full stack with monitoring
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
# E2E test instance alongside prod
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
```
### Volumes
| Volume | Purpose |
| ------ | ------- |
| `pgdata` | All Postgres data — users, encounters, memories, audit logs, settings, embeddings. **Critical for backup.** |
| `scribe-logs` | Filesystem JSONL log files (`data/logs/YYYY-MM-DD.log`). Low priority — Postgres has the same data in `audit_log`. |
| `loki-data` | Loki chunk storage (when monitoring stack is up). |
| `grafana-data` | Grafana settings, saved dashboards. |
### Reverse proxy
App binds `127.0.0.1:3552`, never to a public interface. Caddy / Nginx /
Traefik terminates TLS and forwards to it. `app.set('trust proxy', 1)`
in `server.js:16` makes `req.ip` the original client IP from
`X-Forwarded-For`.
### docker-entrypoint.sh
The container's `ENTRYPOINT`. Optional OpenBao path described in
section 18. Falls through to `exec "$@"` (i.e., `node server.js`)
when OpenBao isn't configured. **Backwards compatible** — legacy
`.env`-only deployments work unchanged.
---
## 18. OpenBao secret loading
`docker-entrypoint.sh` is the integration point. OpenBao is the open-
source fork of HashiCorp Vault that Daniel runs at `app.danvics.com`
for service secrets (Vaultwarden is for human logins; OpenBao is for
machine-to-machine).
### Trigger
The entrypoint checks `OPENBAO_ADDR`. If unset, skip OpenBao entirely
and start the Node process with whatever's already in env (the legacy
`.env` path). If set, `OPENBAO_ROLE_ID` and `OPENBAO_SECRET_ID` are
required (FATAL otherwise).
### Flow
1. **AppRole login.**
```
bao write -field=token auth/approle/login \
role_id="${OPENBAO_ROLE_ID}" \
secret_id="${OPENBAO_SECRET_ID}"
```
Captures the resulting token into `BAO_TOKEN`.
2. **Fetch the KV.**
```
bao kv get -format=json "${OPENBAO_KV_PATH:-kv/ped-ai/prod}" | jq -c '.data.data'
```
The path defaults to `kv/ped-ai/prod`. The response is a single
JSON object of `{ KEY: VALUE, ... }`.
3. **Snapshot pre-existing env.** The entrypoint records the keys
already in env (from docker-compose `env_file`/`environment:`)
into a temp file.
4. **Per-key apply.** For every key returned from OpenBao:
- If the key is **already** in env (set by docker), skip — the
docker-compose override wins. This is critical for the e2e
container: `TURNSTILE_SECRET_KEY=""` from `docker-compose.e2e.yml`
must not be overwritten by OpenBao's real prod value.
- Otherwise, `eval "export $K=$VAL"` (with `@sh` shell-quoted
value to avoid injection).
5. **Unset auth material.**
```
unset OPENBAO_ROLE_ID OPENBAO_SECRET_ID BAO_TOKEN
```
So the Node process doesn't carry them.
6. **`exec "$@"`** — replace the shell with `node server.js`.
### Why
Production secrets (`JWT_SECRET`, `DATA_ENCRYPTION_KEY`, AI provider
keys, SMTP creds, etc.) shouldn't sit in `.env` files on disk. OpenBao
holds them centrally; only `OPENBAO_ROLE_ID` + `OPENBAO_SECRET_ID`
need to be on the host (and AppRole credentials are themselves
rotatable / revocable / auditable).
---
## 19. Optional services
### Loki + Grafana — `docker-compose.monitoring.yml`
- **Loki 3.4.2** at `127.0.0.1:3101`. Internal-only ingest at
`http://loki:3100/loki/api/v1/push`.
- **Grafana 11.6.0** at `127.0.0.1:3003`. Pre-provisioned Loki
datasource + dashboards from `monitoring/dashboards/`.
- The app gets `LOKI_URL=http://loki:3100` injected; `logger.js:1629`
fire-and-forgets every audit/api/access record to Loki in addition
to the Postgres write.
Stack: streams labeled `{ app: 'pedscribe', type: 'audit' | 'api_call' |
'access', ... }`. Full message body is JSON.
### Nextcloud — WebDAV upload
Per-user creds stored in `users.nextcloud_url`, `nextcloud_user`,
`nextcloud_token` (encrypted), `nextcloud_folder`. Routes:
`routes/nextcloud.js` — upload finished notes as `.txt` / `.md` files
via WebDAV PUT.
There's also a Learning Hub WebDAV file browser keyed off
`users.webdav_learning_path`.
### S3 — document storage
Config: `S3_BUCKET`, `S3_REGION`, `S3_PREFIX`, `S3_ENDPOINT`,
`S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE`.
The path-style flag is needed for MinIO, Backblaze B2, and most
non-AWS providers (AWS itself supports virtual-hosted style).
`routes/documents.js` does presigned PUT/GET via the AWS SDK. File
bytes never touch the app's filesystem — direct upload from browser to
S3 in a future revision.
### pgvector — embeddings
Already installed via `pgvector/pgvector:pg16` image. The
`learning_content.embedding` column is `VECTOR(768)`, indexed via
IVFFLAT once ≥ 10 rows have embeddings. Default model:
`text-embedding-005` (Vertex). Switching to a different-dimension
model requires an `ALTER COLUMN embedding TYPE vector(N)` migration
plus re-embedding all content.
### ntfy — push notifications
Optional — set `NTFY_URL` (and `NTFY_TOKEN` if needed). `src/utils/notify.js`
posts to `pedscribe-user-${userId}` topics for new-login alerts and
password-change confirmations. Admin notifications go to
`pedscribe-admin`.
### Cloudflare Turnstile
`TURNSTILE_SITE_KEY` + `TURNSTILE_SECRET_KEY`. When unset, server-side
verification is a no-op and the frontend skips the iframe entirely.
Required for any deployment with public-facing registration.
---
## 20. Sacred zones
These files / behaviors **must not** be casually edited. Per Daniel's
explicit instruction set in MEMORY:
### `public/js/encounters.js` — save / idempotency
Every change requires per-change approval. Even a pre-approved change
gets rejected if it refactors the save path or idempotency logic.
The reason: Daniel has lost notes mid-clinic to encounter-save bugs
twice. The current shape works — defensive `try/catch`, optimistic
locking, idempotency keys, label-uniqueness check before insert.
Refactoring "for clarity" is forbidden unless an existing bug is
named first.
### Voice / STT plumbing
`public/js/audioBackup.js`, `public/js/browserWhisper.js`,
`public/js/voiceDictation.js`, `public/js/liveEncounter.js`,
`public/js/whisperWorker.js`, `public/js/whisperWorkerV2.js`,
`public/js/transcriptionSettings.js`, `public/js/voicePreferences.js`,
`public/js/speechRecognition.js`, plus the server-side
`src/routes/transcribe.js` and `src/utils/transcribe*.js`.
Don't refactor recorder / transcribe plumbing. Fix only **named bugs**
in the **smallest possible diff**. The recorder lifecycle has been
tuned over many cycles to handle: tab backgrounding, OS audio
interruption, MediaRecorder mime negotiation across Chrome/Firefox/
Safari, browser Whisper fallback when network fails, server-side
audio backup on transcription failure. Touching anything outside the
named bug is how the silent-cancel branch became always-taken in
v6.53.0 (fixed in 66f319e).
### Clinical formulas
- `public/js/calculators.js` — pediatric calculators (Bili, BMI, BP
percentiles, GCS, BSA, dosing). Specifically the formula constants
and citation-anchored values.
- `public/js/peGuide.js` SCALES section — heart sound timing,
respiratory exam findings, etc.
- `public/js/bedside/age-weight.js` — Broselow / weight-by-age
estimators, drug dose tables.
These are clinical references with citations. Changes require
peer-reviewed source; no guessing.
### Auth flow
`src/routes/auth.js`, `src/routes/oidc.js`, `src/middleware/auth.js`,
`src/utils/sessions.js`, `src/utils/passwords.js`, `public/js/auth.js`,
`public/js/authFetch.js`. Mature, hardened code with subtle
interactions (sliding idle, BroadcastChannel sync, mobile vs web
transport, OIDC auto-link). Touch with care.
---
## 21. Anti-patterns to avoid
Pulled directly from the patterns visible in the codebase:
### 1. Editing `public/js/encounters.js` without per-change approval
It's sacred. See above. If a feature needs to write to
`saved_encounters`, route it through the existing `POST /api/encounters/saved`
path — don't bypass the wrapper.
### 2. Renaming a button id without grepping handlers
The lint-references.js linter catches the easiest version (id
literally exists nowhere). It does **not** catch ids that exist in
some other component which never loads on the affected tab. Before
renaming any `id="..."`, do `grep -r 'btn-foo' public/`.
### 3. Adding a new feature inside a 1500-line file
`src/routes/learningAI.js` (806 lines), `src/routes/adminConfig.js`
(987 lines), and `public/js/calculators.js` are red-flag sized.
New features should be their own file. Carve out subroutes:
`src/routes/learning-quiz.js`, `src/routes/learning-embed.js`, etc.
The improvement roadmap explicitly calls out "split calculators.js"
as a prioritized refactor.
### 4. Using bare `fetch()` instead of the standard helpers
```js
// WRONG
fetch('/api/foo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(...) })
// RIGHT
fetch('/api/foo', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(...) })
```
The `getAuthHeaders()` helper handles the web/native split. Bare
`fetch()` works on web (the cookie rides automatically) but breaks
silently on native (no Bearer token attached, request 401s).
### 5. Native `alert` / `confirm` / `prompt`
Banned. Use `showToast(msg, type)` and `showConfirm(msg)` from
`app.js`. The native dialogs block the JS thread, look terrible on
mobile WebView, and are inconsistent across browsers.
### 6. Adding "explanatory UI copy"
Banned by Daniel's MEMORY directive
(`feedback_no_explanatory_ui_copy.md`). Skip "your note saves as you
type" / tip-lists / "encrypted at rest" filler. Reads as
AI-generated.
### 7. Suggesting "wipe and reset" as a fix
Banned by Daniel's MEMORY directive
(`feedback_destructive_actions.md`). Even when "nothing important is
stored", work forward from existing state. Don't propose
`pgdata` rebuild, `npm install` from scratch, branch deletion, etc.
as a first move.
### 8. Citation comment blocks added silently
Banned by Daniel's MEMORY directive
(`feedback_no_code_citations.md`). Don't dump
`/* eslint-disable */ // Adapted from https://...` blocks into files
without proposing first. Inline references for load-bearing data
(Fenton growth charts, peditools formulas) are OK without asking.
### 9. Mounting a router globally instead of per-feature
Don't `app.use('/api', authMiddleware)`. Each router uses
`router.use(authMiddleware)` at the top. Globally mounting auth
breaks `/api/health`, `/api/models`, OIDC callbacks, and the public
auth endpoints — none of which should require an authenticated user.
### 10. Direct `db.pool.query` from a route
Use `db.get / db.all / db.run / db.query` from `src/db/database.js`.
The wrapper handles the SQLite-style placeholder conversion and the
auto-RETURNING for inserts. Bypassing it leaks the legacy `?` syntax
detail and makes future driver swaps painful.
---
## 22. How to add a new tab end-to-end
Concrete checklist. Use this as a template.
### 1. Sidebar button
Edit `public/index.html`. Add inside the `<nav id="sidebar-nav">`
section:
```html
<button class="tab-btn" data-tab="mytab">
<span class="tab-icon">🧪</span>
<span class="tab-label">My Tab</span>
</button>
```
(If the tab is admin/moderator-only, add `class="tab-btn hidden"`
and an `id="mytab-tab-btn"` so `auth.js` can toggle visibility.)
### 2. Tab content placeholder
Edit `public/index.html`. Add inside the `<main>` section:
```html
<section id="mytab-tab" class="tab-content" data-component="mytab"></section>
```
The id **must** be `${tabName}-tab` and `data-component` is the
filename (without `.html`) under `public/components/`.
### 3. Component HTML
Create `public/components/mytab.html`:
```html
<div class="container">
<h1>My Tab</h1>
<button id="mytab-do-thing">Do Thing</button>
<pre id="mytab-output"></pre>
</div>
```
No `<script>` tags here — JS lives in `public/js/`.
### 4. Per-tab JS module
Create `public/js/mytab.js`:
```js
// ============================================================
// MYTAB.JS — Demo per-tab module
// ============================================================
(function () {
function init() {
var btn = document.getElementById('mytab-do-thing');
if (!btn || btn._wired) return;
btn._wired = true;
btn.addEventListener('click', async function () {
var resp = await fetch('/api/mytab', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ hello: 'world' })
});
var data = await resp.json();
document.getElementById('mytab-output').textContent = JSON.stringify(data, null, 2);
});
}
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'mytab') init();
});
})();
```
Then add the script tag to `public/index.html` (in the deferred-script
list, alphabetically per existing convention):
```html
<script defer src="/js/mytab.js"></script>
```
### 5. Server route
Create `src/routes/mytab.js`:
```js
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
router.use(authMiddleware);
router.post('/mytab', async function (req, res) {
try {
var hello = (req.body || {}).hello;
if (!hello) return res.status(400).json({ error: 'hello required' });
res.json({ success: true, echoed: hello, user: req.user.email });
logger.audit(req.user.id, 'mytab_call', 'mytab call: ' + hello, req, { category: 'general' });
} catch (e) {
logger.error('POST /mytab', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;
```
### 6. Mount the route in `server.js`
Add to the authenticated feature router list near
`server.js:279303`:
```js
app.use('/api', require('./src/routes/mytab'));
```
### 7. Prompt entry (only if you're calling AI)
If your route calls `callAI`, add the prompt template to
`src/utils/prompts.js`:
```js
PROMPTS.mytab = function (input) {
return `You are a pediatric assistant. ${input}`;
};
```
Live-editable from the Admin Panel via `app_settings` key
`prompt.mytab`.
### 8. Encounter API integration (only if it persists like an encounter)
If your tab needs save/resume, **don't** create a new persistence
table. Use `POST /api/encounters/saved` with a new `enc_type` value
(e.g. `enc_type: 'mytab'`). The tab list at section 11 shows
existing values. Add your new type to whichever client code lists
encounter types (`public/js/encounters.js`'s rendering of saved
encounters).
**This one piece touches sacred code** — get per-change approval.
### 9. Lint check
```bash
node scripts/lint-references.js
```
Should print `✓ All JS id references resolve to an HTML id.` and
`✓ All asset paths resolve to a file on disk.`
### 10. Restart and test
```bash
docker compose restart pediatric-scribe
```
Open the app, click the new tab, click the button, see the JSON
echo. Look at `audit_log`:
```sql
SELECT * FROM audit_log WHERE action = 'mytab_call' ORDER BY timestamp DESC LIMIT 5;
```
### 11. Commit
```
feat(mytab): add demo tab end-to-end
- Sidebar button + tab placeholder
- public/components/mytab.html
- public/js/mytab.js
- src/routes/mytab.js mounted at /api/mytab
- Audit category: general
```
If the new tab is admin/moderator-only, add a role check before the
sidebar button toggle in `auth.js` (look for the existing pattern
around `cms-tab-btn` and `admin-tab-btn`).
---
## Appendix A — File map (compressed)
```
server.js # composition root (388 lines)
docker-entrypoint.sh, Dockerfile, docker-compose{,.local,.e2e,.monitoring}.yml
src/
db/ database.js, migrate.js
middleware/ auth.js, logging.js
routes/ 32 Express routers — see section 9 table
utils/ config, crypto, auditQueue, redact, logger, errors, fileType,
platform, notify, sessions, passwords, promptSafe, prompts,
models, ai, embeddings, transcribe*, tts*
migrations/ # node-pg-migrate, timestamp-prefixed
public/
index.html # ~38 <script> tags, sidebar + placeholders
sw.js # service worker
js/
app.js, auth.js, authFetch.js, secureStorage.js, ui-state.js
encounters.js, audioBackup.js, liveEncounter.js, voiceDictation.js,
browserWhisper.js, whisperWorker*.js, speechRecognition.js,
transcriptionSettings.js, voicePreferences.js # ALL SACRED — STT
calculators.js, calc-math.js, drugs-loader.js, peGuide.js # SACRED — formulas
bedside/index.js # ES-MODULE POCKET (script type="module")
bedside/{age-weight,sub-nav,airway,cardiac,sepsis,...}.js
soap, hospitalCourse, chartReview, milestones, sickVisit, wellVisit,
ed-encounters, shadess, extensions, notes, memories, documents,
nextcloud, learningHub, admin
pediatricScheduleData.js, milestonesData.js, e2e-bootstrap.js
components/ # one HTML fragment per tab (~20 files)
models/ # bundled transformers.js + Whisper-tiny.en
scripts/
lint-references.js, maintenance.js, release.sh, e2e.sh,
download-whisper-models.sh, import-milestones.js
monitoring/ # Loki + Grafana provisioning + dashboards
mobile/ # Capacitor wrapper (Android shipping)
```
---
## Appendix B — Boot sequence one-pager
1. Docker starts container, runs `/app/docker-entrypoint.sh`.
2. If `OPENBAO_ADDR` set: AppRole login, fetch
`kv/ped-ai/prod`, export missing keys to env.
3. `exec node server.js`.
4. `dotenv` reads `.env` (any keys not already in env from step 2).
5. `auditQueue.js` initializes (3 in-memory queues).
6. `database.js` connects pool, runs `initDatabase()`:
- pgvector extension.
- Collation drift check + REINDEX if needed.
- Idempotent baseline DDL.
- Versioned migrations via `migrate.js`.
- Seed default `app_settings`.
7. `database.js` schedules hourly cleanup (`_cleanupInterval`) +
one-shot at +10s.
8. `server.js` loads `package.json` for version banner.
9. `app.set('trust proxy', 1)`.
10. helmet (CSP), CORS (/api scope), cookie-parser, json(10mb).
11. Rate limiters mounted (general /api + auth-specific).
12. BUILD_ID computed; index.html cache-bust template primed.
13. `/`, `/api/build` defined.
14. Logging middleware (`res.json` wrap).
15. Static (`public/`) with per-file Cache-Control.
16. Auth, learning admin, admin routers mounted.
17. Public endpoints (`/api/models`, `/api/health`,
`/api/health/detailed`).
18. 28+ authenticated feature routers mounted.
19. 404 handler.
20. `setTimeout(() => PROMPTS.loadFromDb(db), 3000)` — loads
admin-edited prompt overrides.
21. `server.listen(PORT)` — banner printed.
22. SIGTERM/SIGINT handlers registered (audit drain → DB pool end →
9-second hard exit).
Healthy and serving.
---
## Appendix C — Request lifecycle (compressed)
```
Browser → reverse proxy → 127.0.0.1:3552 → Express
helmet → cors(/api) → cookieParser → json(10mb)
→ rate limiter(general 200/min)
→ rate limiter(endpoint-specific, if any)
→ static OR route mount
└─ router.use(authMiddleware): verify JWT → lookup user →
check disabled → lookup session by token_hash → check idle
(web only) → update last_activity (write methods) →
attach req.user
└─ handler: validate → db.get/all (decrypt) →
callAI? → db.run (encrypt) → res.json
└─ loggingMiddleware fires apiCall log on res.json
└─ handler may also fire audit log
◀ response
↳ auditQueue flushes (1s interval or 50-row batch)
↳ Loki push fire-and-forget (if LOKI_URL set)
```
---
End of document.