pediatric-ai-scribe-v3/src/routes/soap.js
Daniel 1bff2d3aad Batch of security + scale fixes
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.
2026-04-14 05:24:40 +02:00

46 lines
1.8 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts');
const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');
router.post('/generate-soap', authMiddleware, async (req, res) => {
try {
const { transcript, patientAge, patientGender, model, type, additionalInstructions, physicianMemories } = req.body;
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Empty input' });
let prompt;
switch (type) {
case 'subjective': prompt = PROMPTS.soapSubjective; break;
case 'full':
default: prompt = PROMPTS.soapFull;
}
if (additionalInstructions) {
prompt += '\n\nADDITIONAL INSTRUCTIONS (operator-supplied, trusted):\n' + additionalInstructions;
}
prompt += INJECTION_GUARD;
var context = 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown') + '\n\n'
+ wrapUserText('transcript', transcript);
if (physicianMemories) {
context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n'
+ wrapUserText('style_hints', physicianMemories)
+ '\n[END STYLE HINTS]';
}
const result = await callAI([
{ role: 'system', content: prompt },
{ role: 'user', content: context }
], { model });
res.json({ success: true, soap: result.content, model: result.model });
logger.audit(req.user.id, 'generate_soap', 'Generated SOAP note', req, { category: 'clinical' });
} catch (err) {
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;