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.
#2 — Don't-miss tooltip (encounters HPI + sick visit, max 5)
- New POST /api/dont-miss returning {points: [{point, why}]} capped at 5
(cap defended both in the prompt and server-side .slice(0,5))
- New dontMissTooltip prompt in prompts.js
- New suggestDontMiss() helper in app.js mirroring suggestBillingCodes;
inserts an orange-bordered card next to the note output, silent on empty
- Wired into liveEncounter.js (encounter HPI) and sickVisit.js. Not added
to wellvisit/soap/hospital/chart per spec.
#1 — Bedside suture selector
- New ES module public/js/bedside/sutures.js (~300L) following the
burns.js pattern: site × age × tension × cosmetic × contamination ×
hours-since-injury → material, size, technique, removal day range,
glue/Steri-strip alternative, warnings, tetanus reminder.
- 15 anatomic sites covered (face, eyelid, lip vermilion, intraoral,
ear, scalp, neck, trunk, upper/lower ext, hand, foot, joint surface,
genitalia, fingertip).
- Bites: cat/human → don't-close-primarily warning; dog bite to hand →
loose-approximation note. Heavy contamination → delayed primary
closure. >12h non-face/scalp → judgment call note.
- Removal days shown as ranges (3–5, 7–10, 10–14) per source norms,
not single midpoints.
- Subungual hematoma trephination guidance corrected: any painful
hematoma with intact nail and no displaced fracture (especially if
25–50% or more), per current UpToDate guidance.
- Inline citation: Roberts & Hedges 7e (2019), Fleisher & Ludwig 8e,
AAP Section on EM, UpToDate (Pope JV).
- Pill registered in sub-nav SECTIONS + bedside/index.js. Persists
active state via existing UIState helper.
All 46 tests pass.
Four changes batched:
1. ED Encounters tab (new) — multi-stage emergency note with don't-miss
tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters
(generate per-stage + finalize MDM), new ed-encounters.js owning all
client logic, new ed-encounter.html component, new template_ed memory
category. Persists draft to localStorage every keystroke and to
saved_encounters on stage advance. encounters.js touched only to
register the new tab in sessionStorage restore + tabMap (save and
idempotency code untouched).
2. Notes model selector — /notes/from-voice now accepts a client-supplied
model (validated by the existing callAI allow-list); falls back to the
admin default. Added <select class="tab-model-select"> to notes.html
so the existing app.js populator handles options + default.
3. Remove AI-learning-from-corrections — deleted correctionTracker.js,
POST /memories/correction, the corrections branch in
/memories/context, the settings UI section, the FAQ entry, and all
dead trackAIOutput/saveCorrection guards in callers. Legacy
correction_* DB rows are filtered (NOT LIKE) rather than dropped, so
no destructive migration.
4. Fix notes AI framing — /notes/from-voice prompt no longer assumes
"physician dictation". Plain notes (shopping lists, reminders,
ideas) now match the dictation tone instead of being forced into
clinical structure.
All 46 tests pass.
Symptom Daniel reported: "note recording, not working nor going into
textbox". Root cause was on the server side — /api/notes/from-voice
asked the AI for HTML in its prompt, but real-world models return
markdown ~25% of the time. Tiptap's setContent only renders HTML;
markdown comes through as literal text or a partial render, looking
like the textbox didn't fill.
Server (src/routes/notes.js):
• New toHtmlBody() helper. If the AI returned real HTML (any
block tag), pass through. Otherwise run through `marked` so
markdown becomes <p>/<h*>/<strong>/etc.
• Strips ```json / ```html code fences before JSON parsing.
• Stricter JSON-recovery: only accepts {title|body} parsed shape;
falls back to wrapping the AI's full reply via toHtmlBody().
• Final guard: if body would be empty after sanitisation, wrap
the raw transcript so the user can at least edit it manually.
Client (public/js/notes.js):
• applyGeneratedNote prefers _editor.commands.setContent over a
full remount — avoids the toolbar-reattach flicker + the brief
window where the body looked empty.
• Logs to console when the editor target is missing or Tiptap
setContent throws, so a future regression is greppable.
Plus the two infra fixes Daniel approved earlier in the same
session — keeping them in this commit since they're already
deployed and tested:
• src/db/database.js: cleanup interval handle exposed; server
shutdown now clearInterval()s it before pool.end(). Removes
the SIGTERM → 9-second-hang → Docker SIGKILL race.
• src/routes/audioBackups.js: switch multer.memoryStorage() to
diskStorage with cleanup. 10 concurrent 25 MB uploads no
longer pin 250 MB of RAM. Identical user-visible perf since
upload is wire-bound.
New dep: marked@latest (used server-side only in toHtmlBody).
Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.
Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).
Server (src/routes/notes.js):
GET /api/notes now filters deleted_at IS NULL
GET /api/notes/trash new — list trashed items, newest-
deleted first
DELETE /api/notes/:id now soft-deletes (sets deleted_at)
DELETE /api/notes/:id?hard=1 hard-delete, only allowed on items
already in trash (UI bug can't
erase an active note)
POST /api/notes/:id/restore pull a note out of trash
POST /api/notes/trash/empty hard-delete every trashed note for
the user
Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
• Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
• Trash tab shows deleted-at timestamps, Restore + delete-forever
per row, Empty-trash button at the bottom
• Active list and trash count refresh in parallel after every
save / delete / restore
• Delete button in the editor now says "Move to trash" and uses
the showConfirm helper (no native dialogs)
Sanitizer swap (public/js/notes.js):
Replaced the homegrown allowlist walker with DOMPurify (already
loaded from cdnjs in index.html, used by learningHub.js too).
Custom HTML sanitizers historically have bypasses; DOMPurify is
the right primitive.
Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
as new dev deps):
9 contract tests covering script-tag stripping, inline event
handlers, img onerror, iframe/object, style attributes, every
preserved tag in the allowlist, javascript: URI rejection,
null/undefined input, and nested-script-inside-paragraph. Total
test count: 37 → 46 passing.
SW cache bumped to pedscribe-v12-notes5.
Four changes landing together so the UX flows properly.
1. Fix voice-generation model error.
/api/notes/from-voice was passing `req.body.model` (undefined)
through to callAI, which resolved to LITELLM_DEFAULT_MODEL (empty
for LiteLLM deployments with no explicit default) → `model=""` →
LiteLLM 400 "Invalid model name". Server now reads the admin-
configured `models.default` setting, falls back to the
LITELLM_DEFAULT_MODEL env var, and only passes a model if one
resolved. Empty string never reaches the provider.
2. Read mode as the default post-save.
Opening an existing note or saving a new one now lands on a
clean, read-only rendering of the body (sanitized HTML with an
allowlist — p/h2/h3/strong/em/u/s/a/ul/ol/li/blockquote/code/pre).
Click the "Edit" button to switch to the Tiptap editor. Matches
the mental model of "notes are documents I review, not drafts I'm
always editing."
3. Autosave during edit.
Title-input + Tiptap onUpdate trigger a 1.2-second-debounced save.
In-flight saves coalesce: if the user types more while a save is
in progress, a follow-up fires right after it lands so the last
keystroke never gets stranded. beforeunload uses navigator.
sendBeacon to best-effort flush on tab close. The manual Save
button is kept as a "save + switch to reader" shortcut. Ctrl/
Cmd+S still works in the editor.
4. Mobile layout.
The two-pane layout collapses to a single-pane view below 900px,
driven by a data-view attribute on .notes-layout. Back buttons
appear in the reader + editor heads on mobile to return to the
list. Desktop layout unchanged (sidebar + right pane always
visible).
Also:
• CSS specificity fix — .hidden{display:none} was losing to
.notes-rec-indicator{display:inline-flex} on source order, so
the "Recording" indicator was showing when idle. Added explicit
.notes-rec-indicator.hidden + .notes-voice-bar .btn-sm.hidden
overrides at higher specificity.
• Reader-body sanitizer — allowlist of safe tags + attributes;
<a> links get target=_blank + rel=noopener.
• SW cache bumped to pedscribe-v12-notes2 so clients pick up the
new module / component / CSS.
New "Notes" tab under Clinical Tools — a per-user scratchpad that's
explicitly NOT fed into AI prompts (distinct from user_memories).
Two-pane layout: searchable list on the left, rich-text editor with
title + save/edit/delete on the right.
Backend:
migrations/…_add-personal-notes.js — personal_notes table
(id, user_id → users ON DELETE CASCADE, title, body, created_at,
updated_at) with indexes on user_id + (user_id, updated_at).
src/routes/notes.js — CRUD + one AI endpoint:
GET /api/notes list, newest-updated first
GET /api/notes/:id fetch one
POST /api/notes create (title + body required)
PUT /api/notes/:id update
DELETE /api/notes/:id remove
POST /api/notes/from-voice transcript → { title, body }
via callAI (admin-controlled
provider — never selectable by
the clinician).
Body + title encrypted at rest via the same cryptoUtil used for
user_memories; 500-note per-user cap; 200-char title / 50 KB
body limits.
Frontend:
public/components/notes.html — empty-state card ("Hello 👋"),
sidebar list with search, editor head with voice-bar (Dictate /
Pause / Resume / Stop + live timer + pulse indicator), Tiptap
body, metadata footer. Uses existing .tp-* toolbar classes.
public/js/notes.js — lazy-init on first tab activation; Tiptap
editor built from window.Tiptap (same bundle the Content
Manager uses); delegated list clicks; Ctrl/Cmd+S to save;
uses app.js's AudioRecorder + transcribeAudio so the STT
pipeline is shared. On stop → transcribe → /api/notes/from-
voice → drop the AI-structured title + body into the editor;
clinician reviews then saves.
public/css/styles.css — 70 lines of .notes-* styles (card
layout, warm empty-state with gradient icon + tips, pulse
animation for the recording indicator, focus states, hover
nudges).
public/sw.js — bump cache from pedscribe-v12 → pedscribe-v12-
notes1 so clients pick up the new module/component.
Admin-controlled STT provider: the recorder posts its audio blob
to the existing /api/transcribe (Google / LiteLLM / ElevenLabs /
Browser Whisper — whatever admin wired up in Settings). Users
cannot pick the provider from this UI.
The route destructured PROMPTS/INJECTION_GUARD/wrapUserText from
../utils/prompts, but PROMPTS is the module's default export and the
other two live in ../utils/promptSafe. All three resolved to undefined,
so every /api/generate-pe-narrative request crashed with
"Cannot read properties of undefined (reading 'peGuideNarrative')"
and the client surfaced "Request failed" for PE Guide narrative and
summary generation. Split the require into the two-line form that
every other AI route already uses.
New top-level tab positioned after Physical Exam Guide. Per-user
directory of hospital phone extensions and pagers — grouped by location
then type, searchable, soft-deleted.
Data:
- New table user_phone_extensions (id, user_id, location, name, number,
type CHECK (extension|pager), notes, trashed_at, timestamps).
Partial indexes on active vs trashed rows for fast filtering.
- Not PHI — hospital internal phone directory. Plaintext.
API (all user-scoped, all params validated):
- GET /api/extensions?trash=1&q=text — list active or trash, optional search
- POST /api/extensions — create
- PUT /api/extensions/:id — update (requires all three core fields)
- DELETE /api/extensions/:id — soft-delete (sets trashed_at)
- POST /api/extensions/:id/restore — un-trash
- DELETE /api/extensions/:id/purge — hard-delete (only if trashed)
All :id params parsed + validated (positive integer) before query.
All queries parameterized, every WHERE includes user_id scoping.
UI (public/js/extensions.js + components/extensions.html):
- Search bar with 200ms debounce, server-side LIKE on location/name/number/notes
- Add button expands inline form — location (with datalist of existing
locations for autocomplete), name/dept, number, type, optional notes
- Each entry renders as a card: big monospace number, dept, type badge,
edit + delete inline
- Grouped by location → type (Extensions / Pagers subheaders)
- Trash view: toggle shows trashed items with Restore + Purge actions
- Trash count badge on the Trash button updates after every delete/restore
- Delete requires confirm() dialog, then soft-delete (easy to undo)
- Purge from trash requires a second confirm() ("cannot be undone")
- Esc closes the form; form resets between Add and Edit
Replaces the generic one-line-per-component format with a step-level
checklist. Each exam component now contains 3–13 discrete steps, each
with its own Normal/Abnormal/Skip toggle and optional abnormal note.
Physician ticks the exam off step-by-step; report generation
summarises at the component level but knows exactly which steps were
performed.
Example — previously the adolescent "Cranial nerves (II–XII)" was a
single row: "How to perform: Full formal adult-pattern exam. Expected:
All cranial nerves intact." That's unhelpful. Now it's 14 discrete
steps: CN I, CN II acuity, CN II fields, CN II fundoscopy, CN II/III
pupils, CN III/IV/VI EOM, CN V sensation V1/V2/V3, CN V motor, CN V
corneal, CN VII forehead/eye-close/smile/puff, CN VIII, CN IX/X, CN
XI, CN XII — each with specific method and expected finding. Same
depth for MSK: scoliosis = 5 discrete steps (standing inspection,
Adam forward-bend, rib-hump check, scoliometer, plumb-line), joint
stability = 8 named tests (Lachman, anterior drawer, varus/valgus,
McMurray, apprehension, Neer/Hawkins, anterior drawer ankle, talar
tilt), Beighton = 5 per-joint measurements, etc.
Sources cited in code header: Bates' Guide 13th ed, Nelson Textbook
22nd ed, Hutchison's Clinical Methods 25th ed, Fenichel Clinical
Pediatric Neurology 8th ed.
Backend route accepts the flat step array (grouped by component on
the server), passes structured text to the AI with methods and
expected findings per step. Prompts updated to summarise at the
component level rather than step-by-step, so output is clinically
readable.
Scope: MSK + Neuro × 6 age groups (newborn, infant, toddler, preschool,
school-age, adolescent). More systems follow the same pattern —
append to PE_DATA.
New top-level tab (positioned after Catch-Up Schedule) combining two
functions:
1. Study reference — for each (age group, system) shows OSCE-style
components with technique, expected normal finding, and abnormal-
feature watch-list.
2. Documentation generator — physician marks each component
Normal / Abnormal (with free-text detail) / Skip; AI produces a
two-section report (Technique + Findings), narrative or structured
list format.
Scope v1: MSK + Neuro × 6 age groups (newborn, infant, toddler,
preschool, school-age, adolescent). More systems can be added to the
embedded PE_DATA in peGuide.js without route changes.
Files:
- src/routes/peGuide.js — POST /api/generate-pe-narrative (mirrors
milestone-narrative pattern: AppRole-level
injection guard, clinical audit category,
PHI redaction upstream already in place)
- src/utils/prompts.js — peGuideNarrative + peGuideList prompts,
structured two-section output
- public/components/pe-guide.html — demographics bar + sub-pills + cards
- public/js/peGuide.js — embedded PE_DATA (all clinical content),
render + state + AI call
- public/index.html — tab button, section, script include
- server.js — mount route at /api
No schema change. No PHI stored — findings live in memory only, exported
via existing copy/read-aloud/Nextcloud actions.
Two independent PHI-leak hardenings folded together:
1. forgot-password timing oracle
The hit path previously did SELECT + token gen + UPDATE + SMTP send
before responding; the miss path returned after the SELECT. An
attacker could distinguish registered emails by response latency
(SMTP RTT is hundreds of ms). Response is now sent immediately after
Turnstile, with the DB and email work fired-and-forgotten in a
background async block. Hit and miss take identical wall-clock time.
Also hardened req.body.email to tolerate missing/non-string input
instead of throwing 500.
2. logger.file redaction
logger.info/warn/error wrote straight to /app/data/logs/YYYY-MM-DD.log
without going through redact(). Current callers are metadata-only and
safe, but any future caller writing logger.error('boom', req.body)
would silently drop PHI to disk. Route both message and optional data
through redact() — same helper the audit path already uses. Benign
startup messages pass through unchanged; SSN/phone/email/DOB patterns
are tokenised, long note-body-shaped text is truncated.
Extends the existing crypto helper (already used for audio backups and the
Nextcloud token) to cover every column that can hold PHI:
- saved_encounters.transcript, .generated_note, .partial_data
- user_memories.content (templates + Dragon-style corrections)
- user_memories.name (auto-derived from original snippet on corrections,
so effectively PHI)
Reads decrypt transparently. Legacy plaintext rows continue to work —
decryptString passes non-enc1: values through unchanged — so no migration
is required; rows re-encrypt on their next save.
The encounters list query previously used LEFT(transcript, 200) for a
preview. With ciphertext that slice is meaningless, so the route now
fetches the full columns, decrypts in Node, then slices. At 7-day auto-
delete the row count is bounded and the cost is a handful of GCM
decrypts per list call.
user_memories ORDER BY moved from (category, name) to (category, id)
since SQL can no longer order on encrypted names.
Closes the HHS breach-notification safe-harbor gap on at-rest PHI.
- Add logger.audit/access calls to auth route (login, login_failed,
login_blocked, register, password_changed, 2fa_backup_code_used,
2fa_backup_codes_regenerated) — these previously only wrote to DB
via raw SQL, bypassing Loki shipper
- Replace logger.info with logger.apiCall in callAI() so every AI call
ships to Loki with model, tokens, cost, duration
- Add device identifier (parsed user agent) to audit and access logs
- Fix TTS voice/model provider mismatch: auto-detect Vertex voices
(Puck, Charon, Kore, etc.) and ElevenLabs voice IDs, override model
to match provider regardless of what model was previously set
- Fix TTS discovery: model IDs saved to tts.voice are detected and
redirected to tts.model (regex for openai-tts, elevenlabs, vertex-tts)
- Fix STT transcription route: add scribe/elevenlabs/transcri to the
isTranscriptionModel regex so ElevenLabs Scribe uses /audio/transcriptions
endpoint instead of chat completions
- Remove OpenObserve/SigNoz code from logger (reverted to Loki-only)
- Add neonatal assessment calculator: GA classification (extremely preterm through
post term), weight-for-GA percentile (AGA/SGA/LGA) using Fenton 2013 LMS data,
birth weight category (ELBW/VLBW/LBW/normal/macrosomia)
- Add DOCX support via mammoth, PPTX/ODT/EPUB via jszip in Learning Hub content
generator file upload
- Add gatewayUrl() helper for consistent API URL construction — handles
LITELLM_API_BASE with or without /v1 suffix, works with any OpenAI-compatible
gateway (LiteLLM, Bifrost, etc.)
- Fix TTS model/voice separation: discovery now tags items as MODEL or VOICE,
auto-detects provider from voice name (Vertex, ElevenLabs, OpenAI)
- Fix STT discovery to include ElevenLabs Scribe and Chirp models
- Fix TTS discovery to include ElevenLabs and Vertex voices alongside models
- Fix admin model test to bypass allowlist check (skipAllowlistCheck) so
discovered models can be tested before adding
- Fix Nextcloud token decryption in learningAI.js WebDAV browse and file import
- Fix admin embedding test to show DB model name instead of hardcoded default
- Fix admin STT test to use correct endpoint for Whisper models
- Add AI gateway migration guide to configuration docs
- Add Grafana dashboard JSON for Loki log visualization
Age parser (src/routes/billing.js):
- Now sums year + month + week + day matches so "4 yr 11 mo"
(59 months) correctly maps to the 5-11y billing bracket instead
of being billed as 1-4y. Added bounds sanity check.
Graceful SIGTERM shutdown (server.js):
- Closes the HTTP listener first, then drains batched audit queues,
then ends the Postgres pool. 9-second hard deadline to beat
Docker's 10-second SIGKILL. Previously an in-flight note save
during a container restart could truncate the write.
Explicit LLM fallback opt-in (src/utils/ai.js):
- The OpenRouter / LiteLLM silent fallback now requires admin
setting `ai.allow_model_fallback = true` (default: false). If
primary fails and fallback is disabled, the error is surfaced
to the caller. Prevents silent spillover from a BAA-covered
primary to a non-covered fallback.
Prompt injection delimiters (src/utils/promptSafe.js):
- Wraps user transcripts, dictations, refine-instructions, and
pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
appends an explicit system instruction telling the model to
treat the wrapped content as data rather than commands.
- Applied to soap.js, hpi.js, refine.js. Extend to other AI
routes incrementally.
Cross-tab logout sync (public/js/authFetch.js, auth.js):
- BroadcastChannel('pedscribe-auth') — logout in one tab posts
a message; all sibling tabs clear state and reload, dropping
any PHI-containing UI immediately.
Backup code race-free consumption (src/routes/auth.js):
- tryConsumeBackupCode() now uses a Postgres transaction with
SELECT ... FOR UPDATE so concurrent login attempts using the
same code serialize. First wins, second sees the already-
shortened array.
Optimistic encounter locking (migrations/...add-encounter-version):
- saved_encounters.version INTEGER NOT NULL DEFAULT 1
- POST /api/encounters/saved accepts an expected_version and
rejects with 409 if the row has advanced. Falls back to
last-write-wins if the client doesn't pass one (backward compat).
Audit log batching (src/utils/auditQueue.js):
- Audit / api_log / access_log writes are buffered in memory and
flushed every 1s or every 50 entries via one multi-row INSERT.
Under load this reduces DB pressure by ~50x. On SIGTERM the
shutdown path drains the queue before exiting.
Two findings from review:
1. callAI() previously accepted any model string from the client.
POST /api/hpi with { model: "openai/o1" } would call the reasoning
model regardless of whether the operator enabled it. Added
getAllowedModelIds() in src/utils/models.js (60s TTL DB-backed
cache) and a guard at the top of callAI() that rejects with
"model_not_permitted" when the requested ID isn't in the active
roster. No model supplied → silent fallback to DEFAULT_MODEL.
2. Middleware was updating user_sessions.last_activity on every
request, including GETs. Client-side polling (/api/auth/me
heartbeats, dashboard refreshes, log tail calls) kept sessions
alive indefinitely, defeating the 24h sliding idle policy. Now
only POST/PUT/DELETE/PATCH count as "user activity". GETs are
read-only and often automated — they no longer extend the
session. Idle enforcement still runs on every method, so a
24h-idle user still gets kicked on their next GET.
Infrastructure only — no existing data or tables modified.
src/db/migrate.js — programmatic runner, fires at boot after
the existing idempotent initDatabase()
migrations/1744600000000... — intentionally empty example, documents
the file shape. Registered in the new
pgmigrations tracking table so it won't
rerun.
.node-pg-migraterc.json — CLI config (migrations-dir, utc naming)
docs/migrations.md — workflow + conventions
package.json — migrate:up/down/new/status npm scripts
(status is a direct pgmigrations query
since node-pg-migrate v7 lacks a status
subcommand)
src/utils/sessions.js:
- parseUserAgent now recognizes the Capacitor wrapper (UA suffix
"PedScribe-Android" / "PedScribe-iOS") and labels sessions
"PedScribe (Android)" instead of "Chrome on Android".
Going forward: schema changes go in /migrations as versioned files
with up() + down(); the inline init in database.js is the implicit
baseline for everything already in production.
After the hybrid auth migration, web users log in but the
setAuthCookie() helper was never actually called in /login or
/register — only in the OIDC callback. Result: local sign-in worked
until the first page reload, then the user appeared logged out. The
Settings page's Active Sessions list came up empty because
/api/sessions received no auth.
Added setAuthCookie(res, token) calls on successful:
- /register (auto-verified first admin path)
- /login (after TOTP / backup code verification)
Mobile is unaffected — it uses Bearer from Keychain and always has.
settings load2FAStatus():
- Explicit credentials: 'same-origin' on the /me fetch (was relying
on fetch defaults, which can behave oddly in some browsers/edges)
- Fall back to window.CURRENT_USER (cached at login) if /me fails,
so local-auth users still see their password/2FA sections after
a transient error. Keeps cache in sync on each successful fetch.
enterApp():
- Cache the logged-in user object on window.CURRENT_USER so modules
that need the canLocalAuth flag don't have to re-fetch /me.
2FA regenerate modal:
- Previous call passed a wrong-shape options object to showConfirm.
Updated to the correct (message, callback, opts) signature with
input:true, inputType:'password', placeholder, required.
OIDC email_verified check:
- Accept boolean true or string 'true' for robustness. Some IdPs
serialize ID-token booleans as strings.
Endpoint guards (defense-in-depth over hidden UI):
- POST /api/auth/change-password: 400 with SSO-aware message if
the caller's stored password is not a real bcrypt/argon2 hash.
Prior behaviour was to fail at passwords.verify() with an
ambiguous "current password is incorrect".
- POST /api/auth/setup-2fa: 400 with same SSO-aware message for
SSO-only accounts. Prior behaviour allowed TOTP setup on an
account where it could never actually trigger (user never logs
in locally).
OIDC account-link safety (src/routes/oidc.js):
- Auto-link to an existing local account now requires the IdP to
assert email_verified=true in the ID token (or userinfo). If
absent/false, the callback redirects with ?error=email_unverified.
Prevents an attacker at a misconfigured IdP from taking over a
local account by claiming an email they don't own.
- If an existing user already has oidc_sub set and the incoming
sub is different, refuse with ?error=sub_mismatch. Prior
behaviour silently did nothing, hiding a potential attack.
- Audit 'oidc_linked' written on first successful link.
Frontend:
- Added user-facing messages for the two new SSO error codes.
Middleware:
- Log to console.warn + audit_log when a session is killed for
inactivity. Shows up in Grafana/Loki so you can see how often
users actually get kicked. Audit action: 'session_idle_timeout'
- last_activity throttle bumped 5 min → 10 min — halves DB writes
per active user. Idle precision slop widens to 24h00-24h10;
still invisible in practice.
Per-user local-auth visibility:
- /api/auth/me now returns user.canLocalAuth: true when the stored
password is a real bcrypt / argon2 hash, false for the random
blob OIDC auto-creates for SSO-only users.
- Settings page hides "Change Password" and "Two-Factor
Authentication" sections when canLocalAuth is false — those UIs
are meaningless for users whose sign-in lives at the IdP.
- Password hash is not leaked in the /me payload.
Mobile (restating existing behaviour for clarity): no idle check,
365-day JWT in Keychain/Keystore, never auto-logs-out. Only logout
triggers are: manual logout, password change, admin revoke, JWT hit
365d, or app uninstall.
Session model:
Web — 24h sliding idle timeout enforced server-side via
user_sessions.last_activity. 30-day JWT + cookie are a
safety net; middleware is the real clock. Cookie is
re-set on active use so browsers match the sliding window.
Mobile — 365-day JWT, no idle timeout (stays persistent via Keychain
/ Keystore). Detected via User-Agent ("PedScribe" /
"Capacitor") or X-Client: mobile header.
2FA backup codes:
- 10 single-use codes generated when 2FA is first enabled
- Stored as bcrypt hashes in new users.totp_backup_codes column
- Consumed atomically on successful login fallback (when TOTP fails)
- Regenerate endpoint (POST /api/auth/2fa/backup-codes) requires
current password; invalidates prior codes
- Count endpoint (GET /api/auth/2fa/backup-codes/count) powers a
"N codes remaining" indicator on the 2FA settings card
- Modal shows codes exactly once with Copy + Download .txt actions
- Codes cleared when 2FA is disabled
New files:
src/utils/platform.js — isMobileClient() helper
Schema migration (idempotent):
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT
Shortens both JWT expiresIn and httpOnly cookie maxAge to 24h in
auth.js (local + register + reset flows) and oidc.js (SSO callback).
Rationale: shorter absolute session window for a PHI-adjacent app.
No sliding idle refresh — user re-logs in once a day.
idx_users_email and idx_sessions_token_hash now use byte-order
collation so a future ICU library bump cannot silently corrupt the
indexes the way it did this week. The columns themselves retain
their default collation; only the index comparison is C, which is
safe for these because:
- users.email is lowercased ASCII in practice
- user_sessions.token_hash is SHA-256 hex (pure ASCII)
Both are used for equality lookups only, never ORDER BY. Migration
is idempotent, gated on app_settings.migration.text_indexes_c.
Slug indexes on learning_* tables left at default for now — those
are also ASCII in practice but under lighter load; the startup
drift check + auto-REINDEX covers them.
Root cause of recent "invalid credentials on correct password" was
a silent btree index corruption: pgvector/pgvector:pg16 was pulled
with a different ICU library than the one used to build existing
indexes. Queries returned 0 rows even though matching heap rows
existed. Postgres logged nothing (corrupt index → empty result set
is a "successful" query) and the login path never logged unknown-
user attempts (enumeration protection).
Three defenses:
1. Pin postgres image by digest in docker-compose.yml so a
silent pull can't change ICU under our feet.
2. Startup collation-drift check in src/db/database.js:
compares pg_database.datcollversion to the library's actual
version and, on mismatch, runs REINDEX DATABASE + ALTER
DATABASE REFRESH COLLATION VERSION. Logs "Collation versions:
aligned" on clean boot.
3. Server-side console.warn on login lookup-miss (no email, no
audit row — preserves enumeration protection but gives
Grafana/Loki a signal for unusual miss rates).
Root cause for "invalid credentials" on correct password was a
corrupt btree index (idx_users_email) causing user lookups to miss
existing rows. Fixed by REINDEX DATABASE. Keeping a typed catch
around passwords.verify() so any future verify throw is logged
cleanly instead of bubbling as 500.
- App-layer AES-256-GCM crypto helper (src/utils/crypto.js)
- Nextcloud tokens encrypted at rest; transparent migration on next use
- Audio backups encrypted at rest (version byte 0x01 envelope); legacy
rows still decrypt as-is until overwritten
- argon2id password hashing via src/utils/passwords.js with bcrypt
fallback; bcrypt hashes rehashed to argon2id on next successful login.
argon2 package is optional — server keeps running with bcrypt only
until npm install adds the native dep
- PHI redactor for audit log details (src/utils/redact.js) — strips SSN,
phone, email, DoB, long IDs; caps at 500 chars; detects note bodies
- DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in
Learning Hub content rendering
- SRI integrity hashes added for Font Awesome CSS and Chart.js
- Magic-byte file-type verification on document uploads
(src/utils/fileType.js)
- Generic 500 error responses via src/utils/errors.js applied to
nextcloud and audioBackups; full detail still logged server-side
- DATA_ENCRYPTION_KEY env documented in .env.example
Deploy: requires rebuild of the container image to pick up the new
files and `npm install` (adds argon2). Existing users keep working
because bcrypt stays available and crypto helpers pass through
plaintext when the key is not yet set in dev.
- JWT_SECRET fails fast at startup in production
- CORS fails closed if APP_URL + CORS_ORIGINS are both missing
- Explicit HSTS (1y, includeSubDomains, preload)
- Rate limit sensitive auth endpoints (change-password, 2FA)
- /api/health now returns {ok:true}; details gated behind admin auth
- Login enumeration removed — generic 401 + dummy bcrypt on miss
- ReDoS guard: 20KB input cap on /suggest-codes
- showToast uses textContent, no innerHTML
- clearSession() clears service worker caches on logout
- OIDC state is now HMAC-signed and stateless (survives restart)
- SSRF guard on admin-set OIDC issuer (blocks private IPs, requires HTTPS)
Adds docs/mobile-build.md covering APK build, release, git push,
keystore, and troubleshooting for both PedScribe and PedsHub apps.
Loki logs now include:
- User agent string (browser/device identification)
- Session ID (ties actions to specific login session)
- Status field (success/failure)
New logging:
- encounter_load: logged when user opens a saved encounter (with label)
- copy_to_clipboard: logged when user copies note content (PHI access)
- Client event endpoint: POST /api/logs/client-event (auth required)
Encounter save/delete/load all include the encounter label for
patient identification in audit trail.
HIPAA audit trail now covers: who, what, when, from where, which
device, which session, what patient data, success/failure.
New feature: after generating any clinical note, the app automatically
suggests relevant billing codes displayed as clickable chips below the output.
Backend (src/routes/billing.js):
- POST /api/suggest-codes endpoint analyzes note text
- Extracts diagnoses from Assessment section via regex
- Looks up ICD-10 codes: local common pediatric map (40+ conditions)
first, then NLM Clinical Tables API for unknown terms
- Suggests CPT E/M codes based on note type, visit complexity,
ROS/PE system counts, and MDM level estimation
- Supports: outpatient (new/established), well visit (age-based),
ED, inpatient (admit/subsequent/discharge)
Frontend (public/js/app.js):
- suggestBillingCodes() renders collapsible card with ICD-10 and CPT chips
- Click any chip to copy the code to clipboard
- Shows E/M level assessment (diagnosis count, ROS, PE, MDM complexity)
- Disclaimer: "Suggestions only. Always verify codes."
Integration: called after note generation in all 6 tabs
(encounter, SOAP, sick visit, well visit, hospital course, chart review)
- Fix: DELETE all other sessions query used empty string fallback when
req.sessionId was undefined, causing id != '' to match ALL rows
(including current session). Now skips deletion if sessionId unknown.
- Fix: Revoke All endpoint returns error if current session not identified
- Fix: var confirm shadowing window.confirm in password change handler