Age parser (src/routes/billing.js):
- Now sums year + month + week + day matches so "4 yr 11 mo"
(59 months) correctly maps to the 5-11y billing bracket instead
of being billed as 1-4y. Added bounds sanity check.
Graceful SIGTERM shutdown (server.js):
- Closes the HTTP listener first, then drains batched audit queues,
then ends the Postgres pool. 9-second hard deadline to beat
Docker's 10-second SIGKILL. Previously an in-flight note save
during a container restart could truncate the write.
Explicit LLM fallback opt-in (src/utils/ai.js):
- The OpenRouter / LiteLLM silent fallback now requires admin
setting `ai.allow_model_fallback = true` (default: false). If
primary fails and fallback is disabled, the error is surfaced
to the caller. Prevents silent spillover from a BAA-covered
primary to a non-covered fallback.
Prompt injection delimiters (src/utils/promptSafe.js):
- Wraps user transcripts, dictations, refine-instructions, and
pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
appends an explicit system instruction telling the model to
treat the wrapped content as data rather than commands.
- Applied to soap.js, hpi.js, refine.js. Extend to other AI
routes incrementally.
Cross-tab logout sync (public/js/authFetch.js, auth.js):
- BroadcastChannel('pedscribe-auth') — logout in one tab posts
a message; all sibling tabs clear state and reload, dropping
any PHI-containing UI immediately.
Backup code race-free consumption (src/routes/auth.js):
- tryConsumeBackupCode() now uses a Postgres transaction with
SELECT ... FOR UPDATE so concurrent login attempts using the
same code serialize. First wins, second sees the already-
shortened array.
Optimistic encounter locking (migrations/...add-encounter-version):
- saved_encounters.version INTEGER NOT NULL DEFAULT 1
- POST /api/encounters/saved accepts an expected_version and
rejects with 409 if the row has advanced. Falls back to
last-write-wins if the client doesn't pass one (backward compat).
Audit log batching (src/utils/auditQueue.js):
- Audit / api_log / access_log writes are buffered in memory and
flushed every 1s or every 50 entries via one multi-row INSERT.
Under load this reduces DB pressure by ~50x. On SIGTERM the
shutdown path drains the queue before exiting.
143 lines
7.4 KiB
JavaScript
143 lines
7.4 KiB
JavaScript
// ============================================================
|
|
// ENCOUNTERS ROUTES — Save/resume/pause encounter progress
|
|
// ============================================================
|
|
|
|
var express = require('express');
|
|
var router = express.Router();
|
|
var db = require('../db/database');
|
|
var { authMiddleware } = require('../middleware/auth');
|
|
var logger = require('../utils/logger');
|
|
|
|
router.use(authMiddleware);
|
|
|
|
// ── GET all saved encounters for current user ────────────────────────────
|
|
router.get('/encounters/saved', async function(req, res) {
|
|
try {
|
|
var rows = await db.all(
|
|
"SELECT id, label, enc_type, status, created_at, updated_at, expires_at, LEFT(transcript, 200) as transcript_preview, LEFT(generated_note, 200) as note_preview FROM saved_encounters WHERE user_id = $1 AND expires_at > NOW() ORDER BY updated_at DESC",
|
|
[req.user.id]
|
|
);
|
|
res.json({ success: true, encounters: rows });
|
|
} catch (e) { logger.error('GET /encounters/saved', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── GET single saved encounter ───────────────────────────────────────────
|
|
router.get('/encounters/saved/:id', async function(req, res) {
|
|
try {
|
|
var row = await db.get(
|
|
'SELECT * FROM saved_encounters WHERE id = $1 AND user_id = $2 AND expires_at > NOW()',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
if (!row) return res.status(404).json({ error: 'Encounter not found or expired' });
|
|
logger.audit(req.user.id, 'encounter_load', 'Loaded encounter: ' + (row.label || 'unlabeled') + ' (id:' + req.params.id + ')', req, { category: 'clinical' });
|
|
res.json({ success: true, encounter: row });
|
|
} catch (e) { logger.error('GET /encounters/saved/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── POST save/update encounter progress ─────────────────────────────────
|
|
router.post('/encounters/saved', async function(req, res) {
|
|
try {
|
|
var { id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key } = req.body;
|
|
var autoDeleteDays = parseInt(await db.getSetting('site.auto_delete_days') || '7', 10);
|
|
|
|
if (id) {
|
|
// Update existing — optimistic locking via `version` column.
|
|
// Client passes req.body.expected_version (from the last GET). If
|
|
// it doesn't match, another tab/user wrote first and we reject
|
|
// with 409. If client doesn't send a version, we fall back to the
|
|
// old behaviour (last-write-wins) for backwards compat.
|
|
var existing = await db.get('SELECT id, version FROM saved_encounters WHERE id = $1 AND user_id = $2', [id, req.user.id]);
|
|
if (!existing) return res.status(404).json({ error: 'Not found' });
|
|
var expected = req.body.expected_version;
|
|
if (expected != null && Number(expected) !== Number(existing.version || 1)) {
|
|
return res.status(409).json({ error: 'Encounter was modified in another tab or session. Reload to see the latest.', currentVersion: existing.version, yourVersion: expected });
|
|
}
|
|
var newVersion = (Number(existing.version) || 1) + 1;
|
|
var upd = await db.run(
|
|
'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, version=$6, updated_at=NOW() WHERE id=$7 AND user_id=$8 AND (version = $9 OR $9::int IS NULL)',
|
|
[
|
|
label || 'Untitled',
|
|
transcript || '',
|
|
generated_note || '',
|
|
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
|
status || 'active',
|
|
newVersion,
|
|
id, req.user.id,
|
|
expected != null ? Number(expected) : null
|
|
]
|
|
);
|
|
if (upd.changes === 0) {
|
|
// Someone else wrote between our SELECT and UPDATE
|
|
return res.status(409).json({ error: 'Encounter changed under you. Reload and retry.' });
|
|
}
|
|
res.json({ success: true, id: id, version: newVersion });
|
|
logger.audit(req.user.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' });
|
|
} else {
|
|
// Enforce unique label per user (within active/non-expired encounters)
|
|
if (label && label.trim()) {
|
|
var labelDup = await db.get(
|
|
"SELECT id FROM saved_encounters WHERE user_id = $1 AND LOWER(label) = LOWER($2) AND expires_at > NOW()",
|
|
[req.user.id, label.trim()]
|
|
);
|
|
if (labelDup) {
|
|
return res.status(409).json({ error: 'An encounter with this label already exists. Use a unique label or load the existing one.' });
|
|
}
|
|
}
|
|
// Check for duplicate via idempotency_key
|
|
if (idempotency_key) {
|
|
var dup = await db.get(
|
|
'SELECT id FROM saved_encounters WHERE user_id = $1 AND idempotency_key = $2',
|
|
[req.user.id, idempotency_key]
|
|
);
|
|
if (dup) {
|
|
// Update existing instead of creating duplicate
|
|
await db.run(
|
|
'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7',
|
|
[
|
|
label || 'Untitled',
|
|
transcript || '',
|
|
generated_note || '',
|
|
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
|
status || 'active',
|
|
dup.id, req.user.id
|
|
]
|
|
);
|
|
logger.audit(req.user.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' });
|
|
return res.json({ success: true, id: dup.id });
|
|
}
|
|
}
|
|
// Create new
|
|
var result = await db.run(
|
|
'INSERT INTO saved_encounters (user_id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, NOW() + ($9 || \' days\')::INTERVAL)',
|
|
[
|
|
req.user.id,
|
|
label || 'Untitled',
|
|
enc_type || 'encounter',
|
|
transcript || '',
|
|
generated_note || '',
|
|
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
|
status || 'active',
|
|
idempotency_key || null,
|
|
autoDeleteDays
|
|
]
|
|
);
|
|
res.json({ success: true, id: result.lastInsertRowid });
|
|
logger.audit(req.user.id, 'encounter_save', 'Saved encounter: ' + (label || 'unlabeled'), req, { category: 'clinical' });
|
|
}
|
|
} catch (e) { logger.error('POST /encounters/saved', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── DELETE saved encounter ───────────────────────────────────────────────
|
|
router.delete('/encounters/saved/:id', async function(req, res) {
|
|
try {
|
|
var result = await db.run(
|
|
'DELETE FROM saved_encounters WHERE id = $1 AND user_id = $2',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
|
|
res.json({ success: true });
|
|
logger.audit(req.user.id, 'encounter_delete', 'Deleted encounter ' + req.params.id, req, { category: 'clinical' });
|
|
} catch (e) { logger.error('DELETE /encounters/saved/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
module.exports = router;
|