From b57d410ea632304716c127f6f345f2caad3e98aa Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 20:48:58 +0200 Subject: [PATCH] add extension transfer workflow --- README.md | 4 + docs/api-reference.md | 197 ++++++++++++++++++++++++++++++ docs/architecture.md | 19 ++- docs/deployment.md | 15 ++- docs/mobile-build.md | 11 +- public/components/extensions.html | 3 + public/js/extensions.js | 64 ++++++++++ src/routes/extensions.js | 87 ++++++++++++- src/utils/extensionTransfer.js | 156 +++++++++++++++++++++++ src/utils/prompts.js | 20 +-- test/extension-transfer.test.js | 43 +++++++ 11 files changed, 595 insertions(+), 24 deletions(-) create mode 100644 src/utils/extensionTransfer.js create mode 100644 test/extension-transfer.test.js diff --git a/README.md b/README.md index f232149..0e4359c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ The app runs as an authenticated Express/Postgres service with a browser fronten - PE guide and clinical reference content. - Vaccines, catch-up schedules, growth/vitals, bilirubin, BSA, GCS, equipment, and resuscitation helpers. - Mobile-friendly PWA layout for bedside use. +- Per-user phone extension and pager directory with soft-delete, search, ZIP export, and JSON/ZIP import for handoff between users. ### Learning Hub @@ -70,6 +71,8 @@ Health check: curl -fsS http://127.0.0.1:3552/api/health ``` +Prometheus metrics are exposed at `GET /metrics` with the `ped_ai_` metric prefix. + The first registered user becomes an admin unless registration has already been configured differently. ## Core Environment @@ -158,6 +161,7 @@ Primary references: - `docs/learning-hub.md` for the CMS and education workflow. - `docs/configuration.md` for environment variables. - `docs/deployment.md` for production deployment. +- `docs/mobile-build.md` for the Capacitor wrapper and app-store build notes. - `docs/logic/README.md` for the deeper code walkthrough. Some deep `docs/logic/` files still describe historical implementation details. Prefer runtime code and tests when documentation conflicts with current behavior. diff --git a/docs/api-reference.md b/docs/api-reference.md index 3d37114..2ae436e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -17,6 +17,7 @@ Complete endpoint reference for the PedAI application. All endpoints are prefixe - [Audio Backups](#audio-backups) - [Documents](#documents) - [User Preferences](#user-preferences) +- [Phone Extensions And Pagers](#phone-extensions-and-pagers) - [Nextcloud Integration](#nextcloud-integration) - [Learning Hub (Public)](#learning-hub-public) - [Learning Hub CMS (Moderator+)](#learning-hub-cms-moderator) @@ -25,6 +26,7 @@ Complete endpoint reference for the PedAI application. All endpoints are prefixe - [Logs](#logs) - [Milestones (Admin)](#milestones-admin) - [Health](#health) +- [Metrics](#metrics) --- @@ -1072,6 +1074,190 @@ Save the user's preferred WebDAV learning content path. --- +## Phone Extensions And Pagers + +Base path: `/api/extensions`. All endpoints require authentication and operate on the current user's personal directory. + +Portable import/export fields are `location`, `name`, `number`, `type`, and `notes`. Exports intentionally omit database IDs and user IDs. `type` is either `extension` or `pager`. + +### GET /api/extensions + +List active or trashed phone directory entries. + +- **Auth required:** Yes +- **Query parameters:** + | Parameter | Type | Description | + |-----------|------|-------------| + | `trash` | string | `1` or `true` returns trashed entries instead of active entries. | + | `q` | string | Case-insensitive search over location, name, number, and notes. | +- **Response:** + ```json + { + "success": true, + "items": [ + { + "id": 1, + "location": "ED", + "name": "Charge Nurse", + "number": "1234", + "type": "extension", + "notes": "Nights", + "trashed_at": null, + "created_at": "2026-05-08T00:00:00.000Z", + "updated_at": "2026-05-08T00:00:00.000Z" + } + ] + } + ``` + +--- + +### POST /api/extensions + +Create a phone extension or pager entry. + +- **Auth required:** Yes +- **Request body:** + ```json + { + "location": "ED", + "name": "Charge Nurse", + "number": "1234", + "type": "extension", + "notes": "Optional note" + } + ``` +- **Response:** + ```json + { + "success": true, + "id": 1 + } + ``` + +--- + +### PUT /api/extensions/:id + +Update an existing entry owned by the current user. + +- **Auth required:** Yes +- **Path parameters:** + | Parameter | Type | Description | + |-----------|------|-------------| + | `id` | number | Entry ID. | +- **Request body:** Same fields as `POST /api/extensions`. +- **Response:** + ```json + { + "success": true + } + ``` + +--- + +### DELETE /api/extensions/:id + +Move an active entry to trash. This is a soft delete. + +- **Auth required:** Yes +- **Response:** + ```json + { + "success": true + } + ``` + +--- + +### POST /api/extensions/:id/restore + +Restore a trashed entry. + +- **Auth required:** Yes +- **Response:** + ```json + { + "success": true + } + ``` + +--- + +### DELETE /api/extensions/:id/purge + +Permanently delete a trashed entry. + +- **Auth required:** Yes +- **Response:** + ```json + { + "success": true + } + ``` + +--- + +### GET /api/extensions/export + +Export active entries as `pedscribe-extensions.zip`. The ZIP contains `pedscribe-extensions.json`. + +- **Auth required:** Yes +- **Response headers:** + | Header | Description | + |--------|-------------| + | `Content-Type` | `application/zip` | + | `Content-Disposition` | Attachment filename. | + | `X-Export-Count` | Number of exported active entries. | +- **JSON file shape inside ZIP:** + ```json + { + "format": "pedscribe-phone-directory", + "version": 1, + "exported_at": "2026-05-08T00:00:00.000Z", + "items": [ + { + "location": "ED", + "name": "Charge Nurse", + "number": "1234", + "type": "extension", + "notes": "Optional note" + } + ] + } + ``` + +--- + +### POST /api/extensions/import + +Import entries from a JSON request body. Exact duplicates already owned by the user are skipped. + +- **Auth required:** Yes +- **Request body:** Export payload object or an array of entry objects. +- **Response:** + ```json + { + "success": true, + "imported": 2, + "skipped": 1, + "total": 3 + } + ``` + +--- + +### POST /api/extensions/import-file + +Import entries from a multipart file upload. Accepts a PedScribe JSON export or a ZIP export containing JSON. + +- **Auth required:** Yes +- **Request body:** `multipart/form-data` with field `file`. +- **Limits:** Maximum upload size is 1 MB. At most 1000 valid entries are processed. +- **Response:** Same as `POST /api/extensions/import`. + +--- + ## Nextcloud Integration ### POST /api/nextcloud/connect @@ -2142,3 +2328,14 @@ Application health check endpoint. "uptime": "number (seconds)" } ``` + +--- + +## Metrics + +### GET /metrics + +Prometheus scrape endpoint for operational metrics. Metric names use the `ped_ai_` prefix and include HTTP request counters, durations, and in-flight request gauges. Unmatched 404 routes are collapsed under `route="unmatched"` to avoid unbounded path labels. + +- **Auth required:** No +- **Response:** Prometheus text exposition format. diff --git a/docs/architecture.md b/docs/architecture.md index 3107ef2..a9ca6ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,6 @@ # Architecture -Self-hosted, single-tenant clinical documentation platform. Dockerized Node.js -server + PostgreSQL + vanilla-JS SPA. No build step on the frontend. +Self-hosted clinical documentation platform. Dockerized Node.js server, PostgreSQL, Redis, and vanilla-JS SPA. No build step on the frontend. ## Stack @@ -9,9 +8,11 @@ server + PostgreSQL + vanilla-JS SPA. No build step on the frontend. |---|---| | Runtime | Node.js 20 (Alpine) + Express 4 | | Database | PostgreSQL 16 with `pgvector` extension | +| Cache / state | Redis for operational cache, prompt suggestions, and queue groundwork | | Frontend | Vanilla JavaScript SPA, service-worker cache | | Mobile | Capacitor 6 wrapper (Android + iOS) | -| Container | Docker Compose (app + db) | +| Container | Docker Compose (app + db + Redis) | +| Observability | Prometheus metrics at `/metrics`; structured app logs in files, Postgres, and optional Loki | | Reverse proxy | External (Caddy, Nginx, Traefik — any) | ## Repository layout @@ -57,7 +58,6 @@ public/ # SPA js/ # 24 vanilla JS modules components/ # per-tab HTML fragments css/styles.css - models/ # bundled Whisper WASM + model files mobile/ # Capacitor wrapper capacitor.config.json # appId com.pedshub.scribe @@ -132,8 +132,9 @@ tabs so logging out in one tab drops UI in every open tab. |---|---|---|---| | `pediatric-ai-scribe` | `ped-ai-local:latest` (built from repo) | 3000 | 127.0.0.1:3552 | | `pedscribe-db` | `pgvector/pgvector:pg16` | 5432 | not exposed | +| `ped-ai-redis` | Redis | 6379 | not exposed | -Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs). +Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs), and Redis data if persistence is enabled by compose. Application health-check polls `GET /api/health`. A reverse proxy terminates TLS and forwards to `127.0.0.1:3552`. The app is @@ -149,3 +150,11 @@ never bound to a public interface directly. Precached on install: `index.html`, core JS, main stylesheet, login component. Cleared on logout (`caches.keys() → caches.delete()`). + +## Clinical Assistant And MCP + +The clinical assistant can call an external MCP-backed retrieval service. Ped-AI remains responsible for the user workflow, provider selection, prompts, and display. MCP remains responsible for Nextcloud access, indexing, retrieval, and vector search. Clinical answer response caching is intentionally disabled; Redis is used for operational metadata and prompt suggestions, not answer reuse. + +## Speech + +Browser Whisper and browser-local Whisper model downloads are removed from runtime. Speech-to-text routes through configured server-side providers. Browser-native Web Speech remains available only when explicitly enabled by user settings and browser support. diff --git a/docs/deployment.md b/docs/deployment.md index 18dc809..ce4b097 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -10,8 +10,9 @@ | Image | Role | |---|---| -| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push (multi-arch: `linux/amd64` + `linux/arm64`). Pull directly or build from source. | +| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push where configured. Pull directly or build from source. | | `pgvector/pgvector:pg16` | Database. | +| `redis:7-alpine` | Operational Redis cache/state. | ## Build from source @@ -23,8 +24,7 @@ cp .env.example .env docker compose up -d --build ``` -Two containers come up: `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db` -internal only. +The default compose starts `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db` internally, and `ped-ai-redis` internally. ## Minimum `.env` @@ -80,7 +80,7 @@ App sets `trust proxy: 1` so rate limiting uses the original client IP. | Volume | Contents | Backup priority | |---|---|---| | `pgdata` | All user data, encounters, memories, audit logs, settings, embeddings | Critical | -| `scribe-logs` | Filesystem audit log files (JSONL by day) | Low — Postgres also has these in `audit_log` table | +| `scribe-logs` | Filesystem audit log files (JSONL by day) | High for compliance evidence; Postgres also has audit/API/access tables | ### Postgres backup / restore @@ -120,6 +120,7 @@ REINDEXes if the ICU library version changed between image builds. | `GET /api/health` | `{ok:true}` — public, used by Docker health check | | `GET /api/health/detailed` | Provider status — admin-auth required | | `GET /api/build` | Build ID (short git SHA) — useful for debugging cache invalidation | +| `GET /metrics` | Prometheus metrics in text exposition format | Docker health check in `Dockerfile`: every 30 s, wget-spiders `/api/health`. Container marked unhealthy after 5 failures. @@ -127,7 +128,7 @@ Container marked unhealthy after 5 failures. ## Resource footprint - RAM: 256 MB minimum, 512 MB recommended for one instance with a handful of concurrent users. -- Disk: ~220 MB image (self-hosted Whisper WASM included). Postgres size scales with audit log retention. +- Disk: Postgres size scales with audit log retention, saved encounters, documents, and Learning Hub content. - CPU: idle load negligible; AI calls are network-bound on the LLM provider side. ## Production checklist @@ -141,6 +142,7 @@ Container marked unhealthy after 5 failures. - Turnstile keys set for public-facing deployments - Reverse proxy serves valid TLS certs - Postgres dump scheduled off-host +- Log retention and backup policy covers `audit_log`, `api_log`, `access_log`, and filesystem `scribe-logs` ## CI / CD @@ -162,6 +164,7 @@ Triggered by `auto-version.yml` (reads commit messages, bumps + tags via |---|---|---| | App | 3000 | 127.0.0.1:3552 | | Postgres | 5432 | not exposed | +| Redis | 6379 | not exposed | Change the app's external port by editing the `ports:` mapping in `docker-compose.yml`. @@ -174,6 +177,8 @@ Change the app's external port by editing the `ports:` mapping in via `src/utils/auditQueue.js`, drained on SIGTERM. 4. Loki (if `LOKI_URL` set) — pushed fire-and-forget per event. +A central Prometheus/Loki/Grafana stack can also scrape `GET /metrics` and collect Docker logs with Promtail. Keep direct Loki push enabled only for structured application events that are useful for compliance and operations. + ## Auto-cleanup | Target | Policy | Frequency | diff --git a/docs/mobile-build.md b/docs/mobile-build.md index 2281bb3..a263438 100644 --- a/docs/mobile-build.md +++ b/docs/mobile-build.md @@ -1,7 +1,8 @@ -# Mobile build & release +# Mobile Build And Release -Capacitor 6 wrapper. Android only today; iOS project exists but requires macOS -+ Xcode to produce an `.ipa`. +Capacitor 6 wrapper around the hosted Ped-AI web app. The launcher defaults to `https://app.pedshub.com`, lets the user change the server URL, and stores that URL locally. Android is buildable on Linux. The iOS project exists but requires macOS and Xcode to produce an `.ipa`. + +This is not a separate native clinical app. The native shell provides WebView hosting, microphone permission plumbing, secure storage, and mobile packaging for the same authenticated web app. ## One-time setup @@ -69,6 +70,8 @@ Output: `android/app/build/outputs/apk/release/app-release.apk` For Play Store, swap `assembleRelease` → `bundleRelease`; output: `.aab` under `bundle/release/`. +If web assets or Capacitor config changed, run `npx cap sync android` from `mobile/` before building. + ### Single-quote the password Keystore passwords with shell metacharacters (`)`, `$`, `!`, space, etc.) must @@ -109,7 +112,7 @@ user to uninstall + reinstall. | Path | Purpose | |---|---| | `mobile/capacitor.config.json` | appId, name, WebView config, plugin opts | -| `mobile/src/` | launcher HTML (server URL entry) | +| `mobile/src/` | launcher HTML and server URL entry, defaulting to `https://app.pedshub.com` | | `mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java` | JS bridge + WebView mic permission | | `mobile/android/app/src/main/java/com/pedshub/scribe/AudioRecordingService.java` | foreground service for background recording | | `mobile/android/app/src/main/AndroidManifest.xml` | permissions, intents, backup rules | diff --git a/public/components/extensions.html b/public/components/extensions.html index 6b7f8a6..fee540c 100644 --- a/public/components/extensions.html +++ b/public/components/extensions.html @@ -11,6 +11,9 @@ style="width:100%;padding:8px 10px 8px 32px;font-size:14px;border:1px solid var(--g300);border-radius:8px;"> + + + diff --git a/public/js/extensions.js b/public/js/extensions.js index ad0b9fc..4a9a669 100644 --- a/public/js/extensions.js +++ b/public/js/extensions.js @@ -17,6 +17,11 @@ document.getElementById('ext-add-btn').addEventListener('click', onAddClick); document.getElementById('ext-cancel-btn').addEventListener('click', hideForm); document.getElementById('ext-save-btn').addEventListener('click', saveItem); + document.getElementById('ext-export-btn').addEventListener('click', exportItems); + document.getElementById('ext-import-btn').addEventListener('click', function () { + document.getElementById('ext-import-file').click(); + }); + document.getElementById('ext-import-file').addEventListener('change', importItems); document.getElementById('ext-trash-btn').addEventListener('click', toggleTrashMode); document.getElementById('ext-back-active').addEventListener('click', function () { _mode = 'active'; updateModeBanner(); load(); }); @@ -327,6 +332,65 @@ ); } + function exportItems() { + fetch('/api/extensions/export', { headers: getAuthHeaders() }) + .then(function (r) { + if (!r.ok) throw new Error('Export failed'); + var count = parseInt(r.headers.get('X-Export-Count') || '', 10); + return r.blob().then(function (blob) { return { blob: blob, count: count }; }); + }) + .then(function (d) { + var a = document.createElement('a'); + var stamp = new Date().toISOString().slice(0, 10); + a.href = URL.createObjectURL(d.blob); + a.download = 'pedscribe-extensions-' + stamp + '.zip'; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(a.href); + document.body.removeChild(a); + showToast('Exported ' + (Number.isFinite(d.count) ? d.count : 'your') + ' entries', 'success'); + }) + .catch(function () { showToast('Export failed', 'error'); }); + } + + function importItems(e) { + var input = e.target; + var file = input.files && input.files[0]; + if (!file) return; + var count = 'the selected'; + if (/\.json$/i.test(file.name)) { + count = 'the JSON'; + } else if (/\.zip$/i.test(file.name)) { + count = 'the ZIP'; + } + showConfirm( + 'Import ' + count + ' pager/extension entries into your directory? Existing exact duplicates will be skipped.', + function () { submitImportFile(file, input); }, + { confirmText: 'Import' } + ); + } + + function submitImportFile(file, input) { + var form = new FormData(); + form.append('file', file); + var headers = getAuthHeaders(); + delete headers['Content-Type']; + fetch('/api/extensions/import-file', { + method: 'POST', + headers: headers, + body: form + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.success) { showToast(d.error || 'Import failed', 'error'); return; } + showToast('Imported ' + d.imported + ' entries' + (d.skipped ? ', skipped ' + d.skipped + ' duplicates' : ''), 'success'); + load(); + loadTrashCount(); + }) + .catch(function () { showToast('Import failed', 'error'); }) + .finally(function () { input.value = ''; }); + } + function toggleTrashMode() { _mode = (_mode === 'trash') ? 'active' : 'trash'; updateModeBanner(); diff --git a/src/routes/extensions.js b/src/routes/extensions.js index 02e7616..f5ad180 100644 --- a/src/routes/extensions.js +++ b/src/routes/extensions.js @@ -5,24 +5,27 @@ // or purge. List supports async search (q) and trash toggle (trash=1). var express = require('express'); +var multer = require('multer'); var router = express.Router(); var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); +var transfer = require('../utils/extensionTransfer'); + +var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 1024 * 1024 } }); router.use(authMiddleware); function sanitizeType(t) { - return (t === 'pager') ? 'pager' : 'extension'; + return transfer.sanitizeType(t); } function clip(s, n) { - return String(s == null ? '' : s).trim().substring(0, n || 200); + return transfer.clip(s, n); } function parseId(raw) { var n = parseInt(raw, 10); return (Number.isFinite(n) && n > 0) ? n : null; } - // ── GET /api/extensions ───────────────────────────────────── // Query params: // trash=1 → return trashed items only (default: active only) @@ -52,6 +55,83 @@ router.get('/extensions', async function (req, res) { } }); +// ── GET /api/extensions/export — ZIP export for sharing/import ───────── +router.get('/extensions/export', async function (req, res) { + try { + var rows = await db.all( + 'SELECT location, name, number, type, notes FROM user_phone_extensions WHERE user_id = $1 AND trashed_at IS NULL ORDER BY location ASC, type ASC, name ASC', + [req.user.id] + ); + var payload = transfer.exportPayload(rows); + var zip = transfer.createJsonZip('pedscribe-extensions.json', payload); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename="pedscribe-extensions.zip"'); + res.setHeader('X-Export-Count', String(payload.items.length)); + res.send(zip); + } catch (e) { + logger.error('GET /extensions/export', e.message); + res.status(500).json({ error: 'Export failed' }); + } +}); + +// ── POST /api/extensions/import — import another user's JSON export ───── +router.post('/extensions/import', async function (req, res) { + try { + var items = transfer.importItemsFromBody(req.body); + var result = await importItemsForUser(req.user.id, items); + if (!result.success) return res.status(400).json(result); + res.json(result); + } catch (e) { + logger.error('POST /extensions/import', e.message); + res.status(500).json({ error: 'Import failed' }); + } +}); + +// ── POST /api/extensions/import-file — import JSON or ZIP file ───────── +router.post('/extensions/import-file', upload.single('file'), async function (req, res) { + try { + if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'Import file is required' }); + var parsed = transfer.parseImportBuffer(req.file.buffer); + var items = transfer.importItemsFromBody(parsed); + var result = await importItemsForUser(req.user.id, items); + if (!result.success) return res.status(400).json(result); + res.json(result); + } catch (e) { + logger.error('POST /extensions/import-file', e.message); + res.status(400).json({ error: 'Import file must be a valid PedScribe JSON or ZIP export' }); + } +}); + +async function importItemsForUser(userId, items) { + if (!items.length) return { success: false, error: 'No valid entries to import' }; + + var existingRows = await db.all( + 'SELECT location, name, number, type, notes FROM user_phone_extensions WHERE user_id = $1', + [userId] + ); + var existing = Object.create(null); + existingRows.forEach(function(row) { existing[transfer.itemKey(transfer.normalizeExportItem(row))] = true; }); + + var imported = 0; + var skipped = 0; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var key = transfer.itemKey(item); + if (existing[key]) { + skipped++; + continue; + } + await db.run( + 'INSERT INTO user_phone_extensions (user_id, location, name, number, type, notes) VALUES ($1,$2,$3,$4,$5,$6)', + [userId, item.location, item.name, item.number, item.type, item.notes] + ); + existing[key] = true; + imported++; + } + + return { success: true, imported: imported, skipped: skipped, total: items.length }; +} + // ── POST /api/extensions ──────────────────────────────────── router.post('/extensions', async function (req, res) { try { @@ -151,3 +231,4 @@ router.delete('/extensions/:id/purge', async function (req, res) { }); module.exports = router; +module.exports._test = { importItemsForUser }; diff --git a/src/utils/extensionTransfer.js b/src/utils/extensionTransfer.js new file mode 100644 index 0000000..0b85b8b --- /dev/null +++ b/src/utils/extensionTransfer.js @@ -0,0 +1,156 @@ +var zlib = require('zlib'); + +function sanitizeType(t) { + return t === 'pager' ? 'pager' : 'extension'; +} + +function clip(s, n) { + return String(s == null ? '' : s).trim().substring(0, n || 200); +} + +function normalizeExportItem(raw) { + return { + location: clip(raw && raw.location, 120), + name: clip(raw && raw.name, 120), + number: clip(raw && raw.number, 40), + type: sanitizeType(raw && raw.type), + notes: clip(raw && raw.notes, 500) + }; +} + +function isValidImportItem(item) { + return !!(item && item.location && item.name && item.number); +} + +function itemKey(item) { + return [item.location, item.name, item.number, item.type, item.notes].map(function(v) { + return String(v || '').trim().toLowerCase(); + }).join('\u001f'); +} + +function exportPayload(rows) { + return { + format: 'pedscribe-phone-directory', + version: 1, + exported_at: new Date().toISOString(), + items: (rows || []).map(normalizeExportItem).filter(isValidImportItem) + }; +} + +function importItemsFromBody(body) { + var rawItems = Array.isArray(body) ? body : (body && Array.isArray(body.items) ? body.items : []); + var seen = Object.create(null); + return rawItems.slice(0, 1000).map(normalizeExportItem).filter(isValidImportItem).filter(function(item) { + var key = itemKey(item); + if (seen[key]) return false; + seen[key] = true; + return true; + }); +} + +function crc32(buf) { + var table = crc32.table; + if (!table) { + table = crc32.table = []; + for (var i = 0; i < 256; i++) { + var c = i; + for (var j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + table[i] = c >>> 0; + } + } + var crc = 0xffffffff; + for (var k = 0; k < buf.length; k++) crc = table[(crc ^ buf[k]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date) { + var d = date || new Date(); + var time = (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2); + var day = Math.max(1, d.getDate()); + var month = d.getMonth() + 1; + var year = Math.max(1980, d.getFullYear()) - 1980; + return { time: time, date: (year << 9) | (month << 5) | day }; +} + +function createJsonZip(filename, payload) { + var name = Buffer.from(filename || 'pedscribe-extensions.json'); + var data = Buffer.from(JSON.stringify(payload, null, 2)); + var stamp = dosDateTime(new Date()); + var crc = crc32(data); + var local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(0, 8); + local.writeUInt16LE(stamp.time, 10); + local.writeUInt16LE(stamp.date, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + + var central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(stamp.time, 12); + central.writeUInt16LE(stamp.date, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(0, 42); + + var centralOffset = local.length + name.length + data.length; + var centralSize = central.length + name.length; + var end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(1, 8); + end.writeUInt16LE(1, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(centralOffset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([local, name, data, central, name, end]); +} + +function parseImportBuffer(buffer) { + var buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || ''); + if (buf.length >= 4 && buf.readUInt32LE(0) === 0x04034b50) { + if (buf.length < 30) throw new Error('Invalid zip file'); + var method = buf.readUInt16LE(8); + var compressedSize = buf.readUInt32LE(18); + var nameLength = buf.readUInt16LE(26); + var extraLength = buf.readUInt16LE(28); + var dataStart = 30 + nameLength + extraLength; + var dataEnd = dataStart + compressedSize; + if (dataEnd > buf.length) throw new Error('Invalid zip file'); + var data = buf.slice(dataStart, dataEnd); + if (method === 8) data = zlib.inflateRawSync(data); + if (method !== 0 && method !== 8) throw new Error('Unsupported zip compression'); + return JSON.parse(data.toString('utf8')); + } + return JSON.parse(buf.toString('utf8')); +} + +module.exports = { + sanitizeType, + clip, + normalizeExportItem, + isValidImportItem, + itemKey, + exportPayload, + importItemsFromBody, + createJsonZip, + parseImportBuffer +}; diff --git a/src/utils/prompts.js b/src/utils/prompts.js index dea8c31..68c961d 100644 --- a/src/utils/prompts.js +++ b/src/utils/prompts.js @@ -54,9 +54,11 @@ const PROMPTS = { Generate a professional HPI from the doctor-patient encounter transcript. ${CORE_RULES} - Write in third person -- Use OLDCARTS framework (Onset, Location, Duration, Character, Aggravating/Alleviating, Radiation, Timing, Severity) -- Include pertinent positives and negatives MENTIONED -- Note the historian (parent, guardian) +- Use OLDCARTS elements only when they are clinically relevant and explicitly provided; do not force every element into the HPI +- Include only pertinent positives and negatives that were mentioned +- Note the historian/source of history when stated, including parent, guardian, patient, EMS, or outside records +- Preserve clinically important chronology, prior care, home treatments, ED/urgent-care course, and response to treatment when provided +- Do not add severity, duration, associated symptoms, sick contacts, intake/output, fever height, or negatives unless stated - Chronological flow`, hpiDictation: `You are an expert medical scribe. Restructure physician dictation into a polished HPI. @@ -75,6 +77,8 @@ ${CORE_RULES} - Include ED course before admission if mentioned - Include relevant labs/imaging from ED - Include what was done in ED (IVF, meds, etc) +- Keep the core reason for admission clear before listing ED data +- Do not invent baseline status, family history, or social history; include them only if pertinent and provided - More detailed than outpatient HPI - Include relevant social history if pertinent`, @@ -86,21 +90,23 @@ ${CORE_RULES} - Start with arrival vital signs and initial assessment - Chronological progression of care - Include treatments, response to treatment, diet progression -- End with discharge readiness statement -- Include "medically and socially cleared for discharge" if appropriate +- End with discharge readiness only if the provided documentation supports discharge +- Include "medically and socially cleared for discharge" only if explicitly supported by the source material - Mention key labs and their trends +- Mention pending studies, unresolved problems, consultant follow-up, and discharge barriers if provided - Do NOT organize by organ system for short stays unless ICU`, hospitalCourseLong: `You are an expert pediatric inpatient medical documentation specialist. Generate a hospital course organized BY DAY OF HOSPITALIZATION. ${CORE_RULES} -- Format: Day 1 (Date), Day 2 (Date), etc. +- Format by hospital day/date only when the source material supports those labels; otherwise use chronological paragraphs - H&P is before Day 1. First progress note = Day 1 - For each day: key events, assessments, interventions, response - Include relevant labs for each day - Include changes in medications or treatment plan - Include consultant recommendations if any -- End with discharge day summary`, +- Include pending studies, unresolved problems, consultant follow-up, and discharge barriers if provided +- End with discharge day summary only if discharge-day information is provided`, hospitalCourseICU: `You are an expert pediatric ICU documentation specialist. Generate a hospital course organized BY ORGAN SYSTEM for ICU stay. diff --git a/test/extension-transfer.test.js b/test/extension-transfer.test.js new file mode 100644 index 0000000..3c30ba0 --- /dev/null +++ b/test/extension-transfer.test.js @@ -0,0 +1,43 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const transfer = require('../src/utils/extensionTransfer'); + +test('extension export payload removes ids and keeps portable fields', () => { + const payload = transfer.exportPayload([ + { id: 10, location: ' ED ', name: ' Charge ', number: ' 1234 ', type: 'pager', notes: ' Nights ' }, + { id: 11, location: '', name: 'Invalid', number: '5555', type: 'extension' } + ]); + + assert.equal(payload.format, 'pedscribe-phone-directory'); + assert.equal(payload.version, 1); + assert.deepEqual(payload.items, [ + { location: 'ED', name: 'Charge', number: '1234', type: 'pager', notes: 'Nights' } + ]); +}); + +test('extension import normalizes, clips, and deduplicates entries', () => { + const items = transfer.importItemsFromBody({ + items: [ + { location: 'ED', name: 'Charge', number: '1234', type: 'pager', notes: 'Nights' }, + { location: ' ed ', name: ' charge ', number: ' 1234 ', type: 'pager', notes: ' nights ' }, + { location: 'Ward', name: 'Desk', number: '9999', type: 'unknown', notes: null }, + { location: '', name: 'Bad', number: '1111' } + ] + }); + + assert.deepEqual(items, [ + { location: 'ED', name: 'Charge', number: '1234', type: 'pager', notes: 'Nights' }, + { location: 'Ward', name: 'Desk', number: '9999', type: 'extension', notes: '' } + ]); +}); + +test('extension transfer zip round-trips JSON payload', () => { + const payload = transfer.exportPayload([ + { location: 'NICU', name: 'Fellow', number: '7777', type: 'pager', notes: '' } + ]); + const zip = transfer.createJsonZip('pedscribe-extensions.json', payload); + const parsed = transfer.parseImportBuffer(zip); + + assert.deepEqual(parsed.items, payload.items); +});