add extension transfer workflow
This commit is contained in:
parent
f53fb55f15
commit
b57d410ea6
11 changed files with 595 additions and 24 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
style="width:100%;padding:8px 10px 8px 32px;font-size:14px;border:1px solid var(--g300);border-radius:8px;">
|
||||
</div>
|
||||
<button id="ext-add-btn" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add</button>
|
||||
<button id="ext-export-btn" class="btn-sm btn-ghost"><i class="fas fa-file-export"></i> Export</button>
|
||||
<button id="ext-import-btn" class="btn-sm btn-ghost"><i class="fas fa-file-import"></i> Import</button>
|
||||
<input type="file" id="ext-import-file" accept="application/zip,application/json,.zip,.json" class="hidden">
|
||||
<button id="ext-trash-btn" class="btn-sm btn-ghost"><i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span></button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
156
src/utils/extensionTransfer.js
Normal file
156
src/utils/extensionTransfer.js
Normal file
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
43
test/extension-transfer.test.js
Normal file
43
test/extension-transfer.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in a new issue