// ============================================================ // 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 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 id:' + id, req, { category: 'clinical' }); } else { var dup = idempotency_key ? await db.get( 'SELECT id FROM saved_encounters WHERE user_id = $1 AND idempotency_key = $2', [req.user.id, idempotency_key] ) : null; // Old ED clients shared this key across patients; never overwrite its row. if (dup && idempotency_key === 'ed-draft-new') { return res.status(409).json({ error: 'Legacy draft key conflict. Reload the app and load the existing encounter or start a new patient.' }); } // 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 && (!dup || labelDup.id !== dup.id)) { return res.status(409).json({ error: 'An encounter with this label already exists. Use a unique label or load the existing one.' }); } } // Retry the same draft even if its original response was lost. 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 id:' + dup.id, 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 id:' + result.lastInsertRowid, 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;