Recording
- A screen wake lock is held for as long as a recording runs. Browsers drop
the lock whenever the page is hidden, so it is taken again on return —
without that, one glance away ended it for the session. The lock is
reference counted (two recorders cannot release each other's), never
requested while hidden (the request would just be rejected), and a denial
or an unsupported browser leaves the recording running.
- Signing out releases it and stops the recording; nothing is sent, because
the session that owned the audio is gone.
- start() on an already-running recorder is now a no-op instead of replacing
the MediaRecorder and silently dropping everything captured so far.
- A recording that ends by itself — recorder error, or the microphone taken
by another app, unplugged or revoked — takes the same path as pressing
Stop, so it is transcribed and stored rather than left in a tab that still
says "recording". Moving around the workspace already kept recording.
Retention
- Every recording is kept for 24 hours now, not only the ones whose
transcription failed. /api/transcribe already has the audio, so this costs
no second upload, and a storage failure is logged rather than thrown: it
must never lose the transcription someone is waiting for.
- One store (src/utils/audioBackupStore.js) is shared by /api/transcribe and
/api/audio-backups so the two cannot drift. Payload goes to object storage
when AUDIO_BACKUPS_S3_* is set and to the encrypted Postgres column
otherwise; metadata always stays in Postgres, so listing, ownership and
expiry behave the same either way. Object keys are scoped by owner, and
the expiry sweep deletes the object with the row.
Verified against the live database: round trip byte-identical, another user
reads null, 950 -> 48 bytes compressed, expired rows take their objects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
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).
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
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.
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
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).
Security:
- Add session management: users can view/revoke active sessions in Settings
- Add password change in Settings (requires current password, HIBP check)
- Force logout all sessions on password reset
- Fix logout to destroy server-side session (was only clearing cookie)
- Add trust proxy for correct client IP in rate limiting and audit logs
- Add CORS support for multiple domains (CORS_ORIGINS env var)
- Add HIBP breach check endpoint and inline warnings on password fields
Audit logging:
- Add audit logging to all 24 PHI-handling endpoints across 13 route files
- Covers: generation, transcription, TTS, refine, encounters, documents, Nextcloud
- All fire-and-forget (no response delay)
AI improvements:
- Refine now includes original source material (transcript, notes, labs)
so AI can reference the full input when modifying output
- Add correction tracking (trackAIOutput) to sick visit and well visit tabs
- Fix sickvisit missing from encounter save noteIdMap
UI fixes:
- Non-blocking busy bar for transcription and AI generation (replaces full-screen overlay)
- Fix encounter recording: hide record button during recording (was showing two stop buttons)
- Fix ROS/PE "All WNL" stacking duplicate event handlers; add Clear buttons
- Enlarge AI instructions textarea in Learning Hub CMS
Domain:
- Primary domain now app.pedshub.com, with scribe.pedshub.com and peds.danvics.com as CORS origins
Features:
- Admin can add, edit, and delete developmental milestones via dashboard
- Milestones stored in PostgreSQL (developmental_milestones table)
- Client-side loads milestones from API instead of static file
- Import script to migrate existing static data to database
- Organized by age group and domain
- Supports sorting and filtering
Admin UI:
- New section in Admin panel for milestone management
- Filter by age group
- Add/Edit modal with validation
- Delete with confirmation
- Auto-complete for age groups and domains
API Endpoints:
- GET /api/milestones-data - Public endpoint for authenticated users
- GET /api/admin/milestones - List all milestones (admin only)
- GET /api/admin/milestones/meta - Get age groups and domains
- POST /api/admin/milestones - Create milestone
- PUT /api/admin/milestones/:id - Update milestone
- DELETE /api/admin/milestones/:id - Delete milestone
- POST /api/admin/milestones/bulk-import - Bulk import
Usage:
1. Run import script: node scripts/import-milestones.js
2. Access Admin dashboard → Developmental Milestones section
3. Add/Edit/Delete milestones as needed
Audio Backups:
- New audio_backups table in PostgreSQL (bytea, gzip compressed)
- POST /api/audio-backups — upload with gzip compression (level 6)
- GET /api/audio-backups — list user's backups
- GET /api/audio-backups/:id/audio — download decompressed audio
- DELETE /api/audio-backups/:id — delete backup
- Auto-cleanup every hour (24h expiry)
- Frontend saves to server first, falls back to IndexedDB
- Settings shows source badge (server/local) per backup
AI Corrections:
- Corrections list is now expandable — click to view original vs corrected
- Shows red "Original" and green "Corrected to" sections
- Click arrow to expand/collapse each correction
- Date shown on each correction
OIDC/SSO:
- New /api/auth/oidc route with PKCE for secure authorization
- Supports Azure AD, Okta, Keycloak, PocketID, Google, any OIDC provider
- Admin configurable: issuer, client ID/secret, button label
- Option to disable local auth (force SSO only)
- Auto-creates users on first SSO login, links existing by email
- SSO button on login page, hidden until admin enables OIDC
Firefox:
- Show info toast on first recording that live preview requires
Chrome/Edge; server-side transcription still works in all browsers
Backend (src/routes/learningAI.js):
- POST /api/admin/learning/ai-generate
* Accepts: topic text, uploaded file (PDF/TXT/MD/HTML), or Nextcloud WebDAV path
* Extracts text from PDFs via pdf-parse; plain text read directly
* Fetches WebDAV files using stored Nextcloud credentials
* Prompts AI to return structured JSON: title, subject, body (HTML), questions[]
* Strips code fences, falls back to regex JSON extraction if needed
- POST /api/admin/learning/ai-refine — refine existing body with instructions
- GET /api/admin/learning/webdav-browse — PROPFIND-based Nextcloud file browser
- POST /api/admin/learning/webdav-path — save user's default browse path
Frontend:
- AI Generate button in CMS editor header (gradient purple→blue)
- Panel with 3 source tabs: By Topic / Upload File / Nextcloud
- Drag-and-drop file dropzone with DataTransfer API support
- WebDAV file browser: navigate folders, select files, breadcrumb path
- Options: question count, content type (article/quiz/pearl), model selector, instructions
- Refine Current Body: prompt-driven AI rewrite of existing content
- Generated content auto-fills title, subject, type, Tiptap body, quiz questions
Settings:
- Nextcloud section: "Learning Hub Default Browse Path" field (shown when connected)
- Saves to webdav_learning_path column via /api/admin/learning/webdav-path
DB: ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT
- New Learning Hub under Pediatric menu with content feed, category browsing,
search, content viewer, and interactive quizzes (MCQ, true/false)
- Quiz system with per-option wrong-answer explanations and general explanations
- Admin CMS for creating/editing categories, content (articles, pearls, quizzes),
and inline question builder with answer options
- 3-role system: admin (full access), moderator (can manage Learning Hub content),
user (all clinical features + learning hub read access)
- 5 new database tables: learning_categories, learning_content, learning_questions,
learning_options, learning_progress
- User progress tracking with score history per quiz
- Moderators see "Content Manager" tab instead of full Admin panel