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.
45 KiB
Pediatric AI Scribe — Developer Guide
Version: 6.0 | Stack: Node.js / Express / PostgreSQL / Vanilla JS
Table of Contents
- Project Overview
- Architecture
- Directory Structure
- Environment Variables
- Database Schema
- Authentication System
- Backend API Reference
- Frontend Architecture
- AI Integration
- Learning Hub & CMS
- Deployment
- Known Issues & Security Notes
- Adding New Features
- Resetting Admin Password via Console
1. Project Overview
Pediatric AI Scribe is a clinical documentation platform for pediatric healthcare providers. It uses AI (via OpenRouter, AWS Bedrock, or Azure OpenAI) to generate:
- HPI notes from live encounter recordings
- SOAP notes from dictation
- Hospital course summaries
- Chart reviews
- Well-visit notes (including SSHADESS, ROS/PE, milestones)
- Sick visit notes
- Learning Hub content (articles, quizzes, clinical pearls, presentations)
Key design principle: Single-page application. All tabs are lazy-loaded HTML components (/public/components/*.html). JavaScript modules initialize only when their tab is first activated via the tabChanged custom event.
2. Architecture
Browser (Vanilla JS + Tiptap)
|
| HTTP (JWT Bearer token in Authorization header)
|
Express.js (Node.js) — server.js
|
|— Helmet (CSP, security headers)
|— CORS (restricted to APP_URL in production)
|— express-rate-limit (login: 10/15min, register: 5/hr, resend-verify: 3/15min, general: 60/min)
|— cookie-parser
|— Routes (/src/routes/)
|
PostgreSQL (pg driver, no ORM)
|
|— users, app_settings, audit_log, saved_encounters
|— user_memories, learning_*, access_log, api_log
How Requests Flow
- Browser sends HTTP request with
Authorization: Bearer <jwt>header - Express middleware chain: Helmet (security headers) → CORS → rate limiter → body parser → logging middleware → route handler
- Auth middleware (
src/middleware/auth.js) decodes JWT, queriesuserstable, attachesreq.userwith{ id, email, name, role } - Route handler processes the request — for AI routes, calls
callAI()which routes to the configured provider - Database is accessed via the
pgdriver directly (no ORM). All queries use parameterized placeholders ($1,$2) to prevent SQL injection - Response is JSON for API calls, or static files served from
/public
AI Providers
Configured via environment variables. The provider is selected at startup in src/utils/ai.js using this priority:
- AWS Bedrock — if
AWS_BEDROCK_REGIONis set. HIPAA eligible with BAA. Uses@aws-sdk/client-bedrock-runtime. Anthropic models use the native Messages API (InvokeModel); all others use the Converse API. - Azure OpenAI — if
AZURE_OPENAI_ENDPOINTis set. HIPAA eligible. Uses the OpenAI SDK pointed at your Azure endpoint. - OpenRouter — default fallback if
OPENROUTER_API_KEYis set. Routes to 20+ models from various providers. Not HIPAA compliant.
The provider cannot be changed at runtime — it's determined once at startup. To switch providers, update .env and restart the container.
Database Layer
The app uses raw SQL via the pg driver — no ORM (Sequelize, Prisma, etc.). This is intentional:
- Simplicity: Every query is visible and explicit. No magic, no migrations framework, no model definitions to sync.
- Performance: No ORM overhead or N+1 query problems.
- Schema management:
src/db/database.jsrunsCREATE TABLE IF NOT EXISTSon startup, plusALTER TABLE ... ADD COLUMN IF NOT EXISTSfor migrations. This means the schema is always up-to-date when the app starts. - Future ORM migration: If needed, the queries are standard PostgreSQL and can be wrapped by any ORM. The main work would be defining models and replacing direct
db.get()/db.run()calls.
The database.js file exports a helper object (db) with convenience methods:
db.get(sql, params)— returns first row ornulldb.all(sql, params)— returns all rows as arraydb.run(sql, params)— executes INSERT/UPDATE/DELETE, returns{ rowCount }db.getSetting(key)/db.setSetting(key, value)— shorthand forapp_settingstable
3. Directory Structure
/
├── server.js # Express app entry point (route registration, Helmet CSP,
│ # rate limiters, static file serving, error handlers)
├── package.json # Dependencies (~25 production deps, no devDeps)
├── Dockerfile # Multi-stage Node.js 20 Alpine build
├── docker-compose.yml # Production compose (uses Docker Hub image)
├── docker-compose.local.yml # Local development (builds from source, port 3552)
├── admin-cli.js # CLI tool for admin tasks (create user, reset password)
├── DEVELOPER_GUIDE.md # This file
│
├── src/
│ ├── db/
│ │ └── database.js # DB connection pool (pg.Pool), schema init
│ │ # (CREATE TABLE IF NOT EXISTS for all tables),
│ │ # column migrations (ALTER TABLE ADD COLUMN IF NOT EXISTS),
│ │ # helper methods: db.get(), db.all(), db.run(),
│ │ # db.getSetting(), db.setSetting()
│ ├── middleware/
│ │ ├── auth.js # authMiddleware (JWT decode → req.user),
│ │ # adminMiddleware (role === 'admin'),
│ │ # moderatorMiddleware (role === 'admin' || 'moderator')
│ │ └── logging.js # Logs every request to api_log table (method, path, user, IP, duration)
│ ├── routes/
│ │ ├── auth.js # Login, register, 2FA, password reset, /me
│ │ ├── admin.js # User management (admin only)
│ │ ├── adminConfig.js # Site settings, feature flags, AI prompts, models
│ │ ├── encounters.js # Save/load/delete draft encounters
│ │ ├── memories.js # User templates (physical exam, ROS, etc.)
│ │ ├── hpi.js # Generate HPI from encounter/dictation transcript
│ │ ├── soap.js # Generate SOAP note
│ │ ├── hospitalCourse.js # Generate hospital course summary
│ │ ├── chartReview.js # Generate outpatient chart review
│ │ ├── milestones.js # Generate developmental milestone narrative
│ │ ├── wellVisit.js # Well-visit note generation (ROS/PE/ICD-10)
│ │ ├── sickVisit.js # Sick visit note generation
│ │ ├── refine.js # Refine/shorten any generated document
│ │ ├── transcribe.js # Whisper audio transcription
│ │ ├── tts.js # Text-to-speech (if configured)
│ │ ├── nextcloud.js # Nextcloud WebDAV connect/export/disconnect
│ │ ├── learningHub.js # User-facing: feed, content, quiz submission
│ │ ├── learningAdmin.js # CMS: categories, content, questions CRUD
│ │ ├── learningAI.js # AI generation for Learning Hub content
│ │ └── logs.js # Usage/audit/API/access logs + client error
│ └── utils/
│ ├── ai.js # callAI(messages, options) — routes to OpenRouter/Bedrock/Azure.
│ │ # Handles Anthropic InvokeModel (Messages API) vs Converse API,
│ │ # thinking block extraction, fallback model retry, duration tracking.
│ ├── models.js # OPENROUTER_MODELS[], BEDROCK_MODELS[], AZURE_MODELS[]
│ │ # Each model: { id, name, cost, tag, category, bedrockId, maxOut, regions }
│ │ # getBedrockModelId() maps app IDs to Bedrock/inference profile IDs.
│ │ # getAvailableModels() filters by region. getAvailableModelsWithOverrides()
│ │ # applies admin-disabled/custom models from DB.
│ ├── prompts.js # Default prompt templates for every AI route. Loaded on startup,
│ │ # then overridden by DB values (app_settings: 'prompt.*' keys).
│ │ # PROMPTS.get('key') returns the DB override or default.
│ ├── config.js # App configuration helpers
│ └── logger.js # Winston logger (file + console, JSON format)
│
├── public/
│ ├── index.html # Single HTML shell, loads all components
│ ├── 404.html # Custom 404 page
│ ├── css/
│ │ └── styles.css # All CSS (single file, ~750 lines)
│ ├── js/
│ │ ├── app.js # Core: tab switching via data-tab buttons, loadComponent()
│ │ │ # fetches HTML from /components/, global helpers (showToast,
│ │ │ # showLoading, getAuthHeaders, getSelectedModel, etc.)
│ │ ├── auth.js # Login/register/forgot-password forms, JWT storage in
│ │ │ # localStorage ('ped_scribe_token'), enterApp()/clearSession(),
│ │ │ # resend verification link handler, 2FA code input
│ │ ├── admin.js # Admin panel: user management, site settings, SMTP config,
│ │ │ # model enable/disable, prompt editor, announcement banner
│ │ ├── liveEncounter.js # MediaRecorder → Whisper transcription → AI HPI generation.
│ │ │ # Handles start/stop recording, timer, save/load encounters
│ │ ├── voiceDictation.js # Web Speech API (real-time) or Whisper (recorded) dictation
│ │ ├── hospitalCourse.js # Paste/dictate hospital course → AI summary
│ │ ├── chartReview.js # Paste/dictate chart data → AI outpatient review
│ │ ├── soap.js # Paste/dictate → AI SOAP note
│ │ ├── milestones.js # Age-based milestone checklist → AI narrative
│ │ ├── wellVisit.js # Well Visit guide: vaccine schedule display, age calculator
│ │ ├── shadess.js # SSHADESS psychosocial form + ROS/PE checkboxes → AI note
│ │ ├── sickVisit.js # Chief complaint + HPI → AI sick visit SOAP
│ │ ├── nextcloud.js # Nextcloud WebDAV connect/disconnect/export settings UI
│ │ ├── encounters.js # Save/load/delete encounter drafts (shared across all tabs)
│ │ ├── memories.js # User template CRUD (physical exam defaults, ROS, etc.)
│ │ ├── learningHub.js # Learning Hub (user feed, content viewer, quiz engine) +
│ │ │ # CMS (category CRUD, content editor with Tiptap, question
│ │ │ # builder, AI generation panel, Nextcloud file picker,
│ │ │ # slide preview modal). Single file, ~1400 lines.
│ │ ├── milestonesData.js # Static milestone data by age group (2mo → 6yr)
│ │ └── pediatricScheduleData.js # CDC vaccine schedule data + catch-up schedule
│ ├── components/ # Lazy-loaded tab HTML (injected by loadComponent)
│ │ ├── encounter.html ├── dictation.html ├── hospital.html
│ │ ├── chart.html ├── soap.html ├── wellvisit.html
│ │ ├── sickvisit.html ├── vaxschedule.html ├── catchup.html
│ │ ├── learning.html ├── cms.html ├── admin.html
│ │ └── settings.html
│ └── vendor/
│ └── tiptap.bundle.js # Tiptap 2 + extensions (esbuild bundle, self-hosted)
4. Environment Variables
Set in .env file (copy .env.example to get started):
# ── Required ──────────────────────────────────────────────────
DATABASE_URL=postgresql://user:pass@host:5432/dbname
JWT_SECRET=change-this-to-a-random-64-char-string
# ── AI Provider (choose one or let it default to OpenRouter) ──
OPENROUTER_API_KEY=sk-or-... # Default provider
# OR
AZURE_OPENAI_ENDPOINT=https://... # Azure (HIPAA eligible)
AZURE_OPENAI_API_KEY=...
AZURE_DEPLOYMENT_NAME=gpt-4o-mini
AZURE_OPENAI_API_VERSION=2024-08-01-preview
# OR
AWS_BEDROCK_REGION=us-east-1 # AWS Bedrock (HIPAA eligible)
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
# ── Optional ──────────────────────────────────────────────────
OPENAI_API_KEY=sk-... # For Whisper transcription only
APP_URL=https://yourdomain.com # Enables secure CORS + Secure cookies
NODE_ENV=production # Enables production optimizations
PORT=3000 # Default: 3000
# ── Email (for password reset, registration verification) ──────
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASS=...
SMTP_FROM=Pediatric AI Scribe <noreply@example.com>
Note: If no SMTP is configured, registration auto-verifies and password reset won't work. Configure SMTP or use the console reset method (see Section 14).
5. Database Schema
All tables are created automatically on first run by src/db/database.js. The file runs CREATE TABLE IF NOT EXISTS for every table, followed by ALTER TABLE ... ADD COLUMN IF NOT EXISTS migrations for upgrades.
Core Tables
users
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| TEXT UNIQUE | Lowercase | |
| password | TEXT | bcrypt hash (cost 12) |
| name | TEXT | Display name |
| role | TEXT | 'user' | 'moderator' | 'admin' |
| totp_enabled | BOOLEAN | 2FA status |
| totp_secret | TEXT | TOTP secret (base32) |
| disabled | BOOLEAN | Soft disable |
| email_verified | BOOLEAN | |
| verify_token / verify_expires | TEXT / BIGINT | Email verification |
| reset_token / reset_expires | TEXT / BIGINT | Password reset |
| nextcloud_url / nextcloud_user / nextcloud_token / nextcloud_folder | TEXT | Nextcloud integration |
| webdav_learning_path | TEXT | Default WebDAV path for Learning Hub file picker |
| created_at | TIMESTAMPTZ |
app_settings
Key-value store for all site configuration. Read via db.getSetting(key), written via admin panel or direct DB.
Important keys:
registration_enabled—'true'/'false'announcement.enabled/announcement.text/announcement.typesmtp.*— SMTP config (overrides env vars)ai.prompt.*— AI prompt overridesmodel.*— enabled/disabled models
saved_encounters
Draft encounters (7-day auto-expiry). Columns: label, enc_type, transcript, generated_note, partial_data (JSON), status, expires_at.
user_memories
User templates fed into AI generation. category is one of: physical_exam, ros, encounter_format, family_history, assessment_plan, custom.
Learning Hub Tables
learning_categories
Simple category list with name, slug, sort_order.
learning_content
Articles, quizzes, pearls, presentations. Key columns: title, slug, body (HTML for articles/pearls/quizzes; Marp markdown for presentations), content_type (article | quiz | pearl | presentation), published, author_id.
learning_questions
Quiz questions linked to learning_content. question_type: mcq | true_false | multi. explanation = general explanation shown after answering.
learning_options
Answer options for quiz questions. is_correct: boolean, explanation = shown when this wrong option is chosen.
learning_progress
Quiz attempt scores per user per content item.
6. Authentication System
Current implementation: JWT in localStorage
Flow
POST /api/auth/login→ returns{ success, token, user }- Frontend stores token in
localStorageasped_scribe_tokenand inwindow.AUTH_TOKEN - All API calls include
Authorization: Bearer <token>header viagetAuthHeaders() src/middleware/auth.jsvalidates the Bearer token, attachesreq.user- Logout:
clearSession()removes token from localStorage (client-side only)
Token
- Signed with
JWT_SECRETenv var - 7-day expiry
- Payload:
{ userId: number }
Roles
user— standard access (clinical tools only)moderator— can create/edit Learning Hub contentadmin— full access including user management and site settings
Middleware
authMiddleware— validates JWT, populatesreq.useradminMiddleware— run after auth, requiresrole === 'admin'moderatorMiddleware— run after auth, requiresrole === 'admin' OR 'moderator'
2FA
Uses TOTP (speakeasy). If enabled, login returns { requires2FA: true } and the client must POST the TOTP code to complete login.
Session Check on Page Load (auth.js)
var savedToken = localStorage.getItem('ped_scribe_token');
if (savedToken) {
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + savedToken } })
.then(/* if ok → enterApp(), else → clearSession() */);
}
The has-session CSS class on <html> hides the auth screen immediately when a localStorage token exists, preventing a white flash.
7. Backend API Reference
All routes are prefixed /api. Routes requiring auth are marked (A). Admin-only: (ADM). Moderator+: (MOD).
Auth — /api/auth/
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /login |
— | Email + password login. Returns { token, user } |
| POST | /register |
— | Create account (checks registration_enabled setting) |
| GET | /me |
A | Returns current user object |
| POST | /logout |
— | Clears server-side state (currently no-op, kept for future) |
| POST | /setup-2fa |
A | Generates TOTP secret + QR code |
| POST | /verify-2fa |
A | Confirms TOTP code, enables 2FA |
| POST | /disable-2fa |
A | Disables 2FA (requires password) |
| POST | /forgot-password |
— | Sends reset email |
| POST | /reset-password |
— | Sets new password via reset token |
| GET | /registration-status |
— | Returns { registrationEnabled: bool } |
| GET | /verify-email |
— | Verifies email via token in query string |
Clinical — AI Generation
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /generate-hpi-encounter |
A | HPI from live encounter transcript |
| POST | /generate-hpi-dictation |
A | HPI from dictation |
| POST | /generate-soap |
A | SOAP note |
| POST | /generate-hospital-course |
A | Hospital course summary |
| POST | /generate-chart-review |
A | Chart review |
| POST | /generate-milestone-narrative |
A | Milestone narrative |
| POST | /generate-milestone-summary |
A | 3-sentence milestone summary |
| POST | /well-visit/note |
A | Full well-visit note |
| POST | /sick-visit/note |
A | Sick visit SOAP |
| POST | /transcribe |
A | Whisper audio → text (multipart/form-data, field: audio) |
| POST | /refine |
A | Refine existing document |
| POST | /shorten |
A | Shorten existing document |
| POST | /clarify |
A | Find missing info in a document |
Encounters (Save/Load)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /encounters |
A | List user's saved encounters |
| POST | /encounters |
A | Save/update encounter draft |
| DELETE | /encounters/:id |
A | Delete a draft |
User Templates
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /memories |
A | List user's templates |
| POST | /memories |
A | Create template |
| PUT | /memories/:id |
A | Update template |
| DELETE | /memories/:id |
A | Delete template |
| GET | /memories/context |
A | Returns templates formatted for AI injection |
Nextcloud
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /nextcloud/connect |
A | Connect + test Nextcloud credentials |
| POST | /nextcloud/export |
A | Export text file to Nextcloud |
| POST | /nextcloud/disconnect |
A | Remove Nextcloud credentials |
Learning Hub (User-Facing)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /learning/categories |
A | List categories |
| GET | /learning/feed |
A | Paginated published content |
| GET | /learning/category/:slug |
A | Content by category |
| GET | /learning/content/:slug |
A | Single content item + questions |
| POST | /learning/submit-quiz |
A | Submit quiz answers, returns scored results |
| GET | /learning/search |
A | Full-text search |
Learning Hub CMS (Moderator+)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /admin/learning/categories |
MOD | All categories with counts |
| POST | /admin/learning/categories |
MOD | Create category |
| PUT | /admin/learning/categories/:id |
MOD | Update category |
| DELETE | /admin/learning/categories/:id |
MOD | Delete category |
| GET | /admin/learning/content |
MOD | All content (including drafts) |
| GET | /admin/learning/content/:id |
MOD | Single item with questions |
| POST | /admin/learning/content |
MOD | Create content |
| PUT | /admin/learning/content/:id |
MOD | Update content |
| DELETE | /admin/learning/content/:id |
MOD | Delete content + questions |
| POST | /admin/learning/content/:id/questions |
MOD | Add question to content |
| PUT | /admin/learning/questions/:id |
MOD | Update question + options |
| DELETE | /admin/learning/questions/:id |
MOD | Delete question |
| GET | /admin/learning/stats |
MOD | Dashboard stats |
Learning Hub AI (Moderator+)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /admin/learning/ai-generate |
MOD | Generate content from topic/file/Nextcloud (multipart/form-data) |
| POST | /admin/learning/ai-refine |
MOD | Refine body HTML with instructions |
| POST | /admin/learning/preview-slides |
MOD | Render Marp markdown → { css, slides[] } for preview |
| POST | /admin/learning/generate-pptx |
MOD | Marp markdown → .pptx download (pptxgenjs) |
| GET | /admin/learning/webdav-browse |
MOD | PROPFIND Nextcloud folder |
| POST | /admin/learning/webdav-path |
MOD | Save user's default WebDAV path |
Admin (Admin Only)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /admin/users |
ADM | List all users |
| POST | /admin/users |
ADM | Create user |
| PUT | /admin/users/:id |
ADM | Update user (role, disable) |
| DELETE | /admin/users/:id |
ADM | Delete user |
| GET/POST | /admin/config/* |
ADM | Site settings (announcement, SMTP, models, prompts, etc.) |
Logs & Health
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
— | Returns { status: 'running', version, provider } |
| GET | /models |
— | Returns available AI models list |
| POST | /logs/client-error |
— | Client-side error logging (public) |
| GET | /logs/usage |
ADM | API usage log |
| GET | /logs/audit |
ADM | Audit log |
8. Frontend Architecture
Tab Loading (Lazy Components)
Every tab's HTML lives in /public/components/<tabname>.html. When a tab button is clicked, loadComponent() in app.js fetches the HTML, injects it into the tab section, then fires tabChanged event.
// app.js
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
Critical pattern: Every JS module that needs to access tab DOM elements MUST listen for tabChanged, not DOMContentLoaded:
// Correct pattern for every tab module
(function() {
var _inited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'myTab' || _inited) return;
_inited = true;
// Now safe to querySelector elements — they exist in the DOM
var btn = document.getElementById('my-btn');
btn.addEventListener('click', ...);
});
})();
If you use DOMContentLoaded instead, the elements won't exist yet (they're loaded async) and you'll get null.addEventListener errors.
Global Functions (defined in app.js)
These are available everywhere — no imports needed:
| Function | Description |
|---|---|
getAuthHeaders() |
Returns { 'Content-Type': 'application/json', 'Authorization': 'Bearer <token>' } |
getSelectedModel() |
Returns model ID from active tab's selector or global selector |
showLoading(msg) |
Shows full-screen loading overlay |
hideLoading() |
Hides loading overlay |
showToast(msg, type) |
Shows toast notification. type: 'success'|'error'|'info'|'warning' |
setOutputText(el, text) |
Sets text on contenteditable div, converting \n to <br> |
transcribeAudio(blob) |
Sends audio blob to /api/transcribe, returns { success, text } |
createSpeechRecognition() |
Returns Web Speech API recognition instance |
createTimer(el) |
Returns timer object with .start() / .stop() |
Rich Text Editor (Tiptap)
The body editor in the CMS uses Tiptap 2 (headless, no styling framework). The bundle is pre-built at /public/vendor/tiptap.bundle.js and exposes window.Tiptap = { Editor, StarterKit, Link, Underline, TextStyle, Color }.
To rebuild the bundle after updating Tiptap packages:
cat > tiptap-entry.js << 'EOF'
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Underline from '@tiptap/extension-underline';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
window.Tiptap = { Editor, StarterKit, Link, Underline, TextStyle, Color };
EOF
npx esbuild tiptap-entry.js --bundle --format=iife --minify --outfile=public/vendor/tiptap.bundle.js
rm tiptap-entry.js
9. AI Integration
src/utils/ai.js — callAI(messages, options)
The single function used by all routes. It routes to the correct provider automatically.
const { callAI } = require('../utils/ai');
const result = await callAI(
[{ role: 'user', content: 'Generate a note...' }],
{
model: 'google/gemini-2.5-flash', // optional, uses default if omitted
temperature: 0.3, // optional, default 0.3
maxTokens: 4000 // optional, default 4000
}
);
// result = { success: true, content: '...', model: '...', provider: '...', duration: ms }
Bedrock Model Notes
Inference Profiles: Most newer models (Anthropic vendor model 4.x, Meta Llama 4, DeepSeek R1, Amazon Nova, Writer) require cross-region inference profiles. These use a us. prefix on the model ID (e.g. us.anthropic.agent-config-sonnet-4-6). Direct model IDs will return "on-demand throughput not supported" errors.
Max Output Tokens: Some models have low output limits (Cohere Command R/R+: 4096, AI21 Jamba: 4096). The maxOut field in models.js auto-clamps maxTokens in callBedrock().
JSON Sanitization: Some models (notably vendor model Sonnet 4.6, Opus 4.6) output literal newline characters inside JSON string values. learningAI.js includes a sanitizeJsonString() function that escapes these before parsing.
Prompt System
Prompts are defined in src/utils/prompts.js. Admins can override any prompt via the Admin panel (/admin/config/prompts). Overrides are stored in app_settings table and loaded into memory on startup (with 3s grace period for DB readiness).
To add a new prompt:
- Add a default in
prompts.js - Use
PROMPTS.get('your-prompt-key')in your route - The admin panel will auto-discover it
AI Generate for Learning Hub
The src/routes/learningAI.js file handles all Learning Hub AI generation.
For presentations: The AI is prompted to return raw Marp markdown (not JSON). The response is stored in the body column. Detection: content_type === 'presentation'.
For articles/quizzes/pearls: The AI returns JSON:
{
"title": "...",
"subject": "...",
"body": "<p>HTML content</p>",
"questions": [
{
"question_text": "...",
"question_type": "mcq",
"explanation": "...",
"options": [
{ "option_text": "...", "is_correct": true, "explanation": "..." }
]
}
]
}
10. Learning Hub & CMS
Content Types
| Type | Body format | Has questions |
|---|---|---|
article |
HTML (Tiptap) | Optional |
quiz |
HTML (brief intro) | Always |
pearl |
HTML | Optional |
presentation |
Marp markdown | Never |
Quiz Question Types
mcq— Single choice (radio buttons), 4 options, 1 correcttrue_false— 2 options: "True" / "False", 1 correctmulti— Multiple select (checkboxes), scoring: all correct chosen AND no incorrect chosen
PPTX Generation
POST /admin/learning/generate-pptx parses Marp markdown (splits on ---), extracts # headings as slide titles, bullet points as content, and uses pptxgenjs to create a real .pptx. No Chromium required — pure Node.js.
Slide Preview
POST /admin/learning/preview-slides uses @marp-team/marp-core to render Marp markdown to HTML, then extracts individual <section> elements. Returns { css, slides[] }. The frontend renders these one at a time in a full-screen modal with arrow key + swipe navigation.
Content Display
In the Learning Hub viewer, content body is rendered via sanitizeHtml() in learningHub.js. This function allows a safe subset of HTML tags only (no <script>, no on* attributes, no style attributes except class).
11. Deployment
Local Development
cp .env.example .env # Fill in your credentials
docker compose -f docker-compose.local.yml build --no-cache
docker compose -f docker-compose.local.yml up -d
# App runs at http://localhost:3552
Logs & Debugging
View container logs (live):
docker logs -f pediatric-ai-scribe
View last N lines:
docker logs --tail 50 pediatric-ai-scribe
Filter for specific issues:
# AI/Bedrock errors
docker logs pediatric-ai-scribe 2>&1 | grep -i "Bedrock\|LearningAI\|callAI"
# Auth errors
docker logs pediatric-ai-scribe 2>&1 | grep -i "Auth\|login\|verify"
# All errors
docker logs pediatric-ai-scribe 2>&1 | grep -i "error\|ERR\|fail"
Key log prefixes:
| Prefix | Source |
|---|---|
[Bedrock] Model: |
AI response metadata (block types, stop reason) |
[LearningAI] |
JSON parse failures with raw output context |
[Auth] |
Login, registration, verification events |
[TTS] |
Text-to-speech generation |
🤖 Provider: |
Startup: which AI provider is active |
✅ AWS Bedrock: |
Startup: Bedrock configured successfully |
Database logs (PostgreSQL):
docker logs pedscribe-db
Production (Docker Hub image)
# docker-compose.yml (production)
services:
app:
image: danielonyejesi/pediatric-ai-scribe-v3:latest
ports: ["3000:3000"]
env_file: .env
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: your_secure_password
volumes:
- pgdata:/var/lib/postgresql/data
Docker Hub
Repository: danielonyejesi/pediatric-ai-scribe-v3
Tags use versioned format: v5.0, v5.1, etc. Production should always pin to a specific tag.
Git Repository
Repository: ifedan-ed/pediatric-ai-scribe-v3 (private)
Build & Push Process
# 1. Test locally first
docker compose -f docker-compose.local.yml build --no-cache
docker compose -f docker-compose.local.yml up -d
# Test at http://localhost:3552
# 2. When ready, tag and push to Docker Hub
docker tag scribe-pediatric-scribe:latest danielonyejesi/pediatric-ai-scribe-v3:v5.x
docker push danielonyejesi/pediatric-ai-scribe-v3:v5.x
# 3. Update production docker-compose.yml to use new tag
12. Known Issues & Security Notes
Active Known Issues
nodemailerHIGH vulnerability — v6.9.x has an email domain interpretation conflict. Upgrade to^6.10.0when available.unsafe-inlinein CSP —scriptSrcincludes'unsafe-inline'to support inline event handlers in HTML components. Should migrate to event listeners and remove this directive.- JWT in localStorage — Tokens stored in
localStorageare readable by JavaScript and therefore vulnerable to XSS attacks. A future migration tohttpOnlycookies would eliminate this risk. See notes in auth.js and Section 6. window.prompt()inrunAiRefineBody— Uses browser native prompt, which can be blocked in certain contexts. Should be replaced with an inline input field.webdav-learning-pathendpoint — Sits behindmoderatorMiddlewarebut is a user preference that non-moderator users might reasonably need. Consider moving to plainauthMiddleware.
Security Hardening Already In Place
- Helmet.js with custom CSP (no external script sources)
- CORS restricted to
APP_URLin production - Rate limiting on login (10/15min), register (5/hr), forgot-password (5/hr), resend-verification (3/15min), general API (60/min)
- bcrypt cost 12 for password hashing
- JWT with 7-day expiry
- SQL injection protection: all queries use parameterized
?/$1placeholders - Dynamic table names validated against an explicit allowlist (
ALLOWED_SLUG_TABLES) - User input in HTML contexts goes through
sanitizeHtml()(tag allowlist, stripson*attributes) - File upload MIME type validated by extension + content type
- Admin/moderator route protection via middleware
13. Adding New Features
Adding a New Clinical Tab
- Create
public/components/mytab.htmlwith the tab's UI - Add to
index.html:- Tab button:
<button class="tab-btn" data-tab="mytab">...</button> - Tab section:
<section id="mytab-tab" class="tab-content" data-component="mytab"></section> - Script tag:
<script defer src="/js/myTab.js"></script>
- Tab button:
- Create
public/js/myTab.js:(function() { var _inited = false; document.addEventListener('tabChanged', function(e) { if (e.detail.tab !== 'mytab' || _inited) return; _inited = true; // Wire up DOM elements here }); })(); - Create
src/routes/myTab.jswith the API route - Register in
server.js:app.use('/api', require('./src/routes/myTab'));
Adding a New AI Prompt
- In
src/utils/prompts.js, add to the defaults object:'my-prompt': 'You are a pediatric physician...' - In your route:
const prompt = PROMPTS.get('my-prompt') + '\n\n' + userInput - The admin panel will show an editor for this prompt automatically.
Adding a New Learning Hub Content Type
- Add the new type to the
content_typeselector incms.html - Handle it in
toggleEditorMode()inlearningHub.js - Add to the type detection in
buildGeneratePrompt()inlearningAI.js - Handle rendering in
learningHub.jsloadContent()function - No DB migration needed —
content_typeis a free-text column
14. Resetting Admin Password via Console
If you lose admin access and have no SMTP for password reset, use the Docker console:
# Step 1: Get a shell in the running app container
docker exec -it pediatric-ai-scribe sh
# Step 2: Open Node.js REPL
node
# Step 3: Hash your new password
const bcrypt = require('bcryptjs');
const hash = await bcrypt.hash('YourNewPassword123!', 12);
console.log(hash);
// Copy the hash output
# Step 4: Exit Node REPL
.exit
# Step 5: Open a DB shell
# (Exit app container first, then:)
docker exec -it pedscribe-db psql $POSTGRES_USER $POSTGRES_DB
# Step 6: Update the password (paste the hash)
UPDATE users
SET password = '$2a$12$...(your-hash-here)...'
WHERE email = 'your-admin@email.com';
# Verify:
SELECT email, left(password, 7) as hash_prefix FROM users WHERE email = 'your-admin@email.com';
# Exit:
\q
Enabling Registration via Console
docker exec -it pedscribe-db psql $POSTGRES_USER $POSTGRES_DB
UPDATE app_settings SET value = 'true' WHERE key = 'registration_enabled';
\q
Creating First Admin User (empty database)
The first user to register is automatically made admin. Enable registration, register, then disable registration again.
Or directly:
# In the Node REPL inside the app container:
const bcrypt = require('bcryptjs');
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const hash = await bcrypt.hash('YourPassword', 12);
await pool.query(
"INSERT INTO users (email, password, name, role, email_verified) VALUES ($1, $2, $3, 'admin', true)",
['admin@yourdomain.com', hash, 'Admin']
);
pool.end();
15. Version History (Recent)
| Tag | Key changes |
|---|---|
| v3.19 | Login flash fixed (auth screen hidden by CSS default); presentation quiz option; feed labels corrected |
| v3.18 | pdf-parse downgraded to v1.1.1; WebDAV selection UX fixed; topic context on upload/WebDAV tabs; inline refine bar replaces window.prompt(); CSP: removed unsafe-inline (all onclick= converted to data-action delegation); webdav-path moved to /api/user/webdav-path (auth-only) |
| v3.17 | AI panel context-aware options fixed (style.display replaces classList — CSS cascade bug); quiz card redesign |
| v3.16 | DEVELOPER_GUIDE.md created |
| v3.15 | Auth reverted to localStorage tokens; slide preview padding fixed |
| v3.14 | AI panel context-aware options (word count, slide count, quiz toggle); delete wording per type |
| v3.13 | Delete confirm inline bar CSS bug fixed; slide preview in-page modal (arrow/swipe nav); Marp textarea placeholder |
| v3.12 | Delete inline confirm bar; lighter login screen; Presentation type (Marp + pptxgenjs PPTX) |
| v3.11 | AI content generation for Learning Hub (topic/file/Nextcloud, pdf-parse, pptxgenjs) |
| v3.10 | Custom 404 page; server returns 404 for unknown paths |
| v3.8 | Quill replaced with Tiptap 2 (self-hosted bundle, inline link bar) |
| v5.0 | Resend verification link on login + rate limit (3/15min) |
| v5.1–v5.4 | Bedrock model fixes: inference profiles, region filtering, thinking block handling |
| v5.5 | Comprehensive Bedrock fix: all us. prefix IDs, maxTokens clamping |
| v5.6 | Re-add Qwen3 235B |
| v5.7 | Remove Opus 4.6 (JSON issues) |
| v5.8 | Fix JSON parse: sanitize literal newlines in strings; re-add Opus 4.6 |
| v5.9 | Re-add Opus 4.6 with sanitizer; updated DEVELOPER_GUIDE |
| v6.0 | Increase PDF/doc context to 50k chars; maxTokens ceiling to 8k |
16. Current Docker Image
Latest stable: danielonyejesi/pediatric-ai-scribe-v3:v6.0
docker pull danielonyejesi/pediatric-ai-scribe-v3:v6.0
17. PDF & Document Uploads
How It Works
The Learning Hub AI generator accepts documents via two paths — both produce the same result:
- Direct upload — user selects a file from their computer (up to 20 MB)
- Nextcloud WebDAV — user browses their Nextcloud and picks a file
The flow:
extractText()inlearningAI.jsdetects file type by MIME/extension- PDF:
pdf-parsev1.1.1 extracts all text pages into a single string - PPTX/DOCX/TXT: extracted via appropriate parser or read as UTF-8
- Text is truncated to 50,000 characters (~25-30 pages) and sent as context in the AI prompt
- AI generates structured content (title, HTML body, quiz questions) from the full context
Supported File Types
| Extension | Handler | Notes |
|---|---|---|
.pdf |
pdf-parse |
Extracts text only — images, charts, tables are lost |
.pptx |
Text extraction from slides | Slide text only |
.docx |
Text extraction | Body text only |
.txt, .md, .csv |
Read as UTF-8 | Full content preserved |
Limits
- Upload size: 20 MB (
multerlimit inlearningAI.js) - Context sent to AI: 50,000 characters (configurable in
buildGeneratePrompt()) - AI response tokens: 8,000 max (ceiling — model stops when done)
Why No Vector Embeddings / RAG
Embeddings and RAG (Retrieval Augmented Generation) are unnecessary for this use case:
- Single document → single generation — the full text fits in the model's context window
- Most Bedrock models support 100K-200K token inputs — 50,000 chars is well within that
- Embeddings would add complexity (pgvector, chunking, retrieval pipeline) with no benefit
If you later need to search across hundreds of stored documents or handle 200+ page PDFs, then consider pgvector + chunked retrieval. For now, the direct approach is correct.
18. Scalability
Current Architecture (Single Instance)
The app runs as a single Node.js process. This is fine for a team/department deployment (tens to hundreds of concurrent users).
What Scales Well Already
- Stateless JWT auth — no server-side session store; any instance can validate any token
- PostgreSQL — handles concurrent connections well; supports read replicas
- Lazy-loaded component HTML — reduces initial page size; tabs load on demand
- AI calls — fully async; expensive calls don't block other requests
Bottlenecks to Address Before Horizontal Scaling
| Issue | Current | Fix for multi-instance |
|---|---|---|
| Rate limiting | In-memory (per process) | Replace with Redis (rate-limit-redis) |
| File uploads | multer in RAM |
Route uploads to S3/object storage |
| Scheduled cleanup | setTimeout in server.js |
Use a dedicated cron job or DB-scheduled task |
How to Scale Horizontally
# docker-compose with 3 app replicas + nginx load balancer
services:
app:
image: danielonyejesi/pediatric-ai-scribe-v3:latest
deploy:
replicas: 3
environment:
DATABASE_URL: postgresql://... # shared external Postgres
REDIS_URL: redis://redis:6379 # add when rate-limit-redis is wired
nginx:
image: nginx:alpine
# upstream: round-robin across app replicas
redis:
image: redis:7-alpine
postgres:
image: postgres:16-alpine
Cloud deployment options (all work with the current Docker image):
- AWS ECS/Fargate — managed containers, easy auto-scaling
- Railway / Render / Fly.io — simple push-to-deploy with Docker
- Kubernetes — full control, overkill for most deployments
19. Security Architecture — localStorage vs httpOnly Cookies
The app stores JWT tokens in localStorage. This is a deliberate choice appropriate for this scale. The key security facts:
Current protections in place (more important than storage location):
Content-Security-Policy: script-src 'self'— blocks all external scripts and inline JS (v3.18)- Input sanitization via
sanitizeHtml()allowlist on all user-generated HTML - All 26
onclick=inline event handlers removed (v3.18) — reduces XSS surface - Rate limiting on auth endpoints
- Helmet.js security headers
- Parameterized SQL queries throughout
The reality about localStorage vs httpOnly cookies:
"Unless you're a bank or large enterprise, it doesn't really matter. Focus on preventing XSS, because that's what actually matters... fundamentally, the security benefit of using httpOnly cookies is very minimal. If your site suffers any kind of XSS, it makes it slightly more difficult for an attacker to use the auth token." — Security engineering community consensus
httpOnly cookies prevent token copying but not token use — an XSS attacker can still make authenticated requests on the user's behalf regardless of where the token is stored.
If you later want httpOnly cookies: The infrastructure is already in place (cookie-parser, CORS credentials:true). The change is: (1) set cookie on login, (2) remove token from getAuthHeaders(), (3) add /api/auth/logout to clear cookie. See notes in auth.js. This was implemented and reverted in v3.14 — it works but adds CSRF considerations.
Token lifetime: Currently 7 days. For higher security, reduce to 1-2 hours and add refresh token rotation.
Last updated: March 2026 — v6.0 Generated for developer handover.