From 64ac4ff6bbb1597104193f1ece86be563405d111 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 14 Apr 2026 05:06:19 +0200 Subject: [PATCH] Add node-pg-migrate for versioned schema changes + better mobile UA labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .node-pg-migraterc.json | 8 ++ docs/migrations.md | 115 ++++++++++++++++++++++ migrations/1744600000000_example-no-op.js | 29 ++++++ package.json | 8 +- src/db/database.js | 15 ++- src/db/migrate.js | 43 ++++++++ src/utils/sessions.js | 10 ++ 7 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 .node-pg-migraterc.json create mode 100644 docs/migrations.md create mode 100644 migrations/1744600000000_example-no-op.js create mode 100644 src/db/migrate.js diff --git a/.node-pg-migraterc.json b/.node-pg-migraterc.json new file mode 100644 index 0000000..5087d84 --- /dev/null +++ b/.node-pg-migraterc.json @@ -0,0 +1,8 @@ +{ + "migrations-dir": "migrations", + "migration-filename-format": "utc", + "migration-file-language": "js", + "migrations-table": "pgmigrations", + "schema": "public", + "verbose": true +} diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..0c75e4b --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,115 @@ +# Database Migrations + +The app uses [node-pg-migrate](https://github.com/salsita/node-pg-migrate) +for versioned, reversible schema changes. + +## How it works + +Boot sequence: + +1. **Baseline init** — `src/db/database.js` runs `CREATE TABLE IF NOT EXISTS` + and `ALTER TABLE ADD COLUMN IF NOT EXISTS` for the existing schema. This + is the implicit baseline — everything that was in place before + migrations were introduced. Idempotent on every boot. +2. **Migrations** — `src/db/migrate.js` runs every file in `/app/migrations/` + that hasn't already been recorded in the `pgmigrations` table, in + filename order. Each applied migration is inserted into `pgmigrations` + so it only runs once. + +New schema changes should go in versioned migration files, not in the +inline `database.js` init. + +## Creating a migration + +```bash +docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_avatar_url +``` + +Creates a file like `migrations/1744601234567_add_avatar_url.js` with +empty `up()` and `down()` functions. Edit it: + +```js +exports.up = (pgm) => { + pgm.addColumn('users', { + avatar_url: { type: 'text', notNull: false } + }); + pgm.createIndex('users', 'avatar_url'); +}; + +exports.down = (pgm) => { + pgm.dropIndex('users', 'avatar_url'); + pgm.dropColumn('users', 'avatar_url'); +}; +``` + +Full API: https://salsita.github.io/node-pg-migrate/ + +## Running migrations + +Migrations apply automatically on app boot. To run them manually (e.g. +before a restart): + +```bash +docker exec -w /app pediatric-ai-scribe npm run migrate:up +``` + +## Rolling back + +Roll back the most recent migration: + +```bash +docker exec -w /app pediatric-ai-scribe npm run migrate:down +``` + +This calls the file's `down()`. If `down()` is empty or missing, the +rollback is a no-op but the migration is removed from `pgmigrations` +— meaning the next `up` will reapply it. + +## Viewing state + +Which migrations have been applied: + +```bash +docker exec -w /app pediatric-ai-scribe npm run migrate:status +``` + +Or directly: + +```bash +docker exec pedscribe-db psql -U pedscribe -d pedscribe \ + -c "SELECT id, name, run_on FROM pgmigrations ORDER BY id;" +``` + +## Raw SQL migrations + +If pgm's JS helpers are limiting, drop to SQL: + +```js +exports.up = (pgm) => { + pgm.sql(` + CREATE INDEX CONCURRENTLY idx_audit_log_action + ON audit_log (action) + WHERE action IN ('login', 'login_failed', 'session_idle_timeout'); + `); +}; +``` + +Note: `CREATE INDEX CONCURRENTLY` cannot run inside a transaction. For +that you need `exports.disableTransaction = true;` in the migration file. + +## Conventions + +- One logical change per file. Don't bundle unrelated alters. +- Always write `down()` unless rollback is fundamentally impossible + (e.g., dropping a column that had unique data). +- Name files by what the change does (`add_foo`, `backfill_bar`), not + the ticket number. +- Migrations run in filename order — the timestamp prefix ensures order + across checkouts from different devs. +- Never edit an already-applied migration. Write a new one to fix it. + +## Relationship to the inline init + +`src/db/database.js` still runs on every boot and handles the pre-migration +baseline. Do not add new schema changes there — use migrations. The inline +init will gradually shrink as old CREATE TABLE statements age out. diff --git a/migrations/1744600000000_example-no-op.js b/migrations/1744600000000_example-no-op.js new file mode 100644 index 0000000..fbdfb3e --- /dev/null +++ b/migrations/1744600000000_example-no-op.js @@ -0,0 +1,29 @@ +/** + * Example migration — demonstrates the shape. + * This one is a NO-OP so the tooling can boot cleanly without + * interfering with the existing baseline in src/db/database.js. + * + * For a real change, replace the body with: + * exports.up = (pgm) => { + * pgm.addColumn('users', { + * avatar_url: { type: 'text' } + * }); + * }; + * exports.down = (pgm) => { + * pgm.dropColumn('users', 'avatar_url'); + * }; + * + * Full API: https://salsita.github.io/node-pg-migrate/ + */ + +exports.up = async () => { + // intentionally empty +}; + +exports.down = async () => { + // intentionally empty +}; + +// Tell node-pg-migrate this migration doesn't need a transaction — +// lets future migrations that need CREATE INDEX CONCURRENTLY etc run. +exports.shorthands = undefined; diff --git a/package.json b/package.json index 4a2833c..4d552e0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,12 @@ "scripts": { "start": "node server.js", "maint:check": "node scripts/maintenance.js check", - "maint:reindex": "node scripts/maintenance.js reindex" + "maint:reindex": "node scripts/maintenance.js reindex", + "migrate": "node-pg-migrate", + "migrate:up": "node-pg-migrate up", + "migrate:down": "node-pg-migrate down 1", + "migrate:status": "node -e \"const{Pool}=require('pg');const p=new Pool({connectionString:process.env.DATABASE_URL});p.query('SELECT id,name,run_on FROM pgmigrations ORDER BY id').then(r=>{console.table(r.rows);p.end();}).catch(e=>{console.error(e.message);process.exit(1);})\"", + "migrate:new": "node-pg-migrate create" }, "dependencies": { "@marp-team/marp-cli": "^4.3.1", @@ -29,6 +34,7 @@ "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "node-pg-migrate": "^7.7.0", "nodemailer": "^8.0.5", "openai": "^4.73.0", "openid-client": "^6.8.2", diff --git a/src/db/database.js b/src/db/database.js index a671a5e..9924131 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -417,7 +417,20 @@ async function initDatabase() { } } -initDatabase(); +// Boot sequence: inline init (idempotent baseline) → node-pg-migrate for +// incremental changes. Migrations live in /app/migrations/. +(async function() { + await initDatabase(); + try { + var { runMigrations } = require('./migrate'); + await runMigrations(); + } catch (e) { + // runMigrations already logs the fatal; don't hide it but don't crash + // the whole process here either — server.js health check catches a + // broken DB separately. + console.error('[DB] Migration runner failed:', e.message); + } +})(); // Clean up expired saved encounters and audio backups async function cleanupExpired() { diff --git a/src/db/migrate.js b/src/db/migrate.js new file mode 100644 index 0000000..e07111a --- /dev/null +++ b/src/db/migrate.js @@ -0,0 +1,43 @@ +// ============================================================ +// Run pending migrations at boot. Uses node-pg-migrate +// programmatically — same engine the CLI uses, no shell-out. +// ============================================================ +// +// Design notes: +// - `src/db/database.js` runs its inline, idempotent CREATE TABLE IF +// NOT EXISTS init on every boot. That is the implicit "baseline" +// schema. node-pg-migrate tracks incremental changes layered on top. +// - Migration files go in /app/migrations/{timestamp}_name.js with +// an up(pgm) and optional down(pgm). +// - Migration state persists in the `pgmigrations` table so each +// file runs exactly once. +// ============================================================ + +var runner = require('node-pg-migrate').default; +var path = require('path'); + +async function runMigrations() { + var databaseUrl = process.env.DATABASE_URL + || 'postgresql://pedscribe:pedscribe_secret_change_me@localhost:5432/pedscribe'; + try { + var applied = await runner({ + databaseUrl: databaseUrl, + dir: path.join(__dirname, '..', '..', 'migrations'), + migrationsTable: 'pgmigrations', + direction: 'up', + count: Infinity, + verbose: false, + singleTransaction: true + }); + if (applied && applied.length) { + console.log('✅ Migrations applied: ' + applied.map(function(m) { return m.name; }).join(', ')); + } else { + console.log('✅ Migrations: up-to-date'); + } + } catch (err) { + console.error('[FATAL] Migration failed:', err.message); + throw err; + } +} + +module.exports = { runMigrations: runMigrations }; diff --git a/src/utils/sessions.js b/src/utils/sessions.js index fd60c27..3588ce0 100644 --- a/src/utils/sessions.js +++ b/src/utils/sessions.js @@ -6,6 +6,16 @@ function hashToken(token) { function parseUserAgent(ua) { if (!ua) return 'Unknown device'; + + // Native PedScribe app (Capacitor appends "PedScribe-Android" / "PedScribe-iOS") + if (/PedScribe-Android/i.test(ua)) return 'PedScribe (Android)'; + if (/PedScribe-iOS/i.test(ua)) return 'PedScribe (iOS)'; + if (/Capacitor/i.test(ua)) { + if (/Android/i.test(ua)) return 'PedScribe (Android)'; + if (/iPhone|iPad|iOS/i.test(ua)) return 'PedScribe (iOS)'; + return 'PedScribe app'; + } + var browser = 'Unknown browser'; var os = 'Unknown OS';