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.
2166 lines
90 KiB
Markdown
2166 lines
90 KiB
Markdown
# 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:155–204`
|
||
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:109–116` 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:198–205` 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:156–170`), 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:6–24`) 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:62–64`. 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:32–60`
|
||
|
||
```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:290–304`'s call to `/api/models`).
|
||
|
||
### `activateTab` — `public/js/app.js:67–91`
|
||
|
||
```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:167–182`) |
|
||
| `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:124–149`).
|
||
- On mobile (`window.innerWidth <= 768`), tab clicks auto-close the
|
||
sidebar (`app.js:86–89`).
|
||
- `#btn-menu-toggle`, `#btn-sidebar-close`, `#sidebar-overlay` are
|
||
delegated through a single document-level click listener in
|
||
`app.js:109–116`. 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:260–273`
|
||
|
||
```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:25–31`) 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:1–7`** — `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:12–13`** — 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:21–54`** — 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:59–80`** — 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:63–66`). 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:95–137`** — 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:140–144`** — `/.well-known/assetlinks.json` for TWA
|
||
(Trusted Web Activity / Android app verification). Must come
|
||
**before** the static handler.
|
||
11. **`server.js:153–196`** — 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:198–205`** — `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:211–226`** — `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:229–303`** — 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:317–322`** — Fallback `GET /` and 404 handler.
|
||
18. **`server.js:329–332`** — 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:334–343`** — Listen on `process.env.PORT || 3000`.
|
||
Print the banner.
|
||
20. **`server.js:345–387`** — 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:229–303`:
|
||
|
||
| 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:512–518`
|
||
|
||
```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:439–450`
|
||
|
||
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:453–471`
|
||
|
||
```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:32–48` (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:329–332`) — 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:380–399`).
|
||
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:81–105`, 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:303–304`):
|
||
|
||
```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:441–449`); 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:6–17`:
|
||
|
||
```
|
||
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:13–41`. Records:
|
||
endpoint, method, status, sizes, model, tokens, cost estimate,
|
||
duration, IP, error.
|
||
|
||
Cost is a hardcoded rate table at `logger.js:32–48` — 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:64–116` 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:16–29`
|
||
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:279–303`:
|
||
|
||
```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.
|