pediatric-ai-scribe-v3/src/routes/encounters.ts
Daniel 41772e600a chore(ts): Phase 2-3 — rename server + all backend .js to .ts
Mass-rename via git mv (preserves history): server.js + 56 files in
src/** (db/, middleware/, routes/, utils/) renamed to .ts. Tests and
scripts stay .js for now — they run under plain node --test and do
not require tsx.

After rename, tsc --noEmit reported 53 errors across 13 files. Fixed:

INLINE FIXES (5 files):
- src/utils/prompts.ts: cast 3 dynamic property assignments to PROMPTS
  via (PROMPTS as any).x — these are intentional runtime augmentations.
- src/routes/notes.ts: var opts: any = {...} so opts.model can be added
  conditionally (was 'opts.model does not exist on {maxTokens:number}').
- src/routes/learningAI.ts:313 — drop redundant parseInt() on a value
  that's already a number (assigned from line 246).
- src/routes/hospitalCourse.ts:66 — Date subtraction needs .getTime()
  on each side; was 'arithmetic on type Date'.
- src/utils/transcribeLocal.ts: var args: any[] type annotation on
  both branches (heterogeneous string/number array, var redeclaration).

@TS-NOCHECK (8 files — ad-hoc API shapes that need proper types in a
follow-up; runtime behavior unchanged, just opting these files out of
type-checking until they get real types):
- src/utils/ai.ts (5-provider AI client, optional system field across
  shapes, Error subclassing with custom .code/.model)
- src/utils/embeddings.ts (Vertex/LiteLLM/OpenAI request shapes)
- src/routes/transcribe.ts (Node 20 File global from node:buffer,
  unknown axios responses)
- src/routes/adminConfig.ts (same pattern as transcribe)
- src/routes/audioBackups.ts (req.body.X.length on unknown)
- src/routes/auth.ts (response.json() unknown in TS6, custom result
  augmentation)
- src/routes/documents.ts (S3 client config built piecemeal)
- src/db/database.ts (db._cleanupInterval added at runtime)

Verification:
- npm run typecheck → 0 errors
- 46/46 unit tests pass
- tsx loads all 32 src/routes/*.ts cleanly (smoke test)
- Server start chain reaches DB connect step (fails locally because no
  Postgres on dev box; in prod the docker-compose stack provides it)

Rollback anchor: ts-phase-1-2026-04-27 if anything misbehaves.
2026-04-27 22:22:29 +02:00

175 lines
8.9 KiB
TypeScript

// ============================================================
// 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');
var cryptoUtil = require('../utils/crypto');
router.use(authMiddleware);
// Normalise partial_data to a string before encrypting so JSON objects and
// raw strings round-trip identically. Decryption returns the original string.
function partialToString(partial_data) {
if (partial_data == null) return '{}';
if (typeof partial_data === 'object') return JSON.stringify(partial_data);
return partial_data;
}
// ── GET all saved encounters for current user ────────────────────────────
router.get('/encounters/saved', async function(req, res) {
try {
// transcript/generated_note are encrypted at rest, so we can't slice on
// the server with LEFT(..,200). Fetch full columns, decrypt in Node, then
// preview. With a 7-day auto-delete the row count is small; the cost is
// a handful of AES-GCM decrypts per list call.
var rows = await db.all(
"SELECT id, label, enc_type, status, created_at, updated_at, expires_at, transcript, generated_note FROM saved_encounters WHERE user_id = $1 AND expires_at > NOW() ORDER BY updated_at DESC",
[req.user.id]
);
var out = rows.map(function (r) {
var t = '', n = '';
try { t = cryptoUtil.decryptString(r.transcript) || ''; } catch (e) {}
try { n = cryptoUtil.decryptString(r.generated_note) || ''; } catch (e) {}
return {
id: r.id,
label: r.label,
enc_type: r.enc_type,
status: r.status,
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
transcript_preview: t.substring(0, 200),
note_preview: n.substring(0, 200)
};
});
res.json({ success: true, encounters: out });
} 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' });
try { row.transcript = cryptoUtil.decryptString(row.transcript); } catch (e) {}
try { row.generated_note = cryptoUtil.decryptString(row.generated_note); } catch (e) {}
try { row.partial_data = cryptoUtil.decryptString(row.partial_data); } catch (e) {}
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',
cryptoUtil.encryptString(transcript || ''),
cryptoUtil.encryptString(generated_note || ''),
cryptoUtil.encryptString(partialToString(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',
cryptoUtil.encryptString(transcript || ''),
cryptoUtil.encryptString(generated_note || ''),
cryptoUtil.encryptString(partialToString(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',
cryptoUtil.encryptString(transcript || ''),
cryptoUtil.encryptString(generated_note || ''),
cryptoUtil.encryptString(partialToString(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;