pediatric-ai-scribe-v3/src/routes/audioBackups.js
Daniel eec9950343 fix(notes): voice → AI note now produces HTML the editor can render
Symptom Daniel reported: "note recording, not working nor going into
textbox". Root cause was on the server side — /api/notes/from-voice
asked the AI for HTML in its prompt, but real-world models return
markdown ~25% of the time. Tiptap's setContent only renders HTML;
markdown comes through as literal text or a partial render, looking
like the textbox didn't fill.

Server (src/routes/notes.js):
  • New toHtmlBody() helper. If the AI returned real HTML (any
    block tag), pass through. Otherwise run through `marked` so
    markdown becomes <p>/<h*>/<strong>/etc.
  • Strips ```json / ```html code fences before JSON parsing.
  • Stricter JSON-recovery: only accepts {title|body} parsed shape;
    falls back to wrapping the AI's full reply via toHtmlBody().
  • Final guard: if body would be empty after sanitisation, wrap
    the raw transcript so the user can at least edit it manually.

Client (public/js/notes.js):
  • applyGeneratedNote prefers _editor.commands.setContent over a
    full remount — avoids the toolbar-reattach flicker + the brief
    window where the body looked empty.
  • Logs to console when the editor target is missing or Tiptap
    setContent throws, so a future regression is greppable.

Plus the two infra fixes Daniel approved earlier in the same
session — keeping them in this commit since they're already
deployed and tested:

  • src/db/database.js: cleanup interval handle exposed; server
    shutdown now clearInterval()s it before pool.end(). Removes
    the SIGTERM → 9-second-hang → Docker SIGKILL race.
  • src/routes/audioBackups.js: switch multer.memoryStorage() to
    diskStorage with cleanup. 10 concurrent 25 MB uploads no
    longer pin 250 MB of RAM. Identical user-visible perf since
    upload is wire-bound.

New dep: marked@latest (used server-side only in toHtmlBody).
2026-04-25 01:35:10 +02:00

129 lines
6 KiB
JavaScript

// ============================================================
// AUDIO BACKUPS ROUTES — Server-side audio backup storage
// Stores compressed audio in PostgreSQL, auto-deletes after 24h
// ============================================================
var express = require('express');
var router = express.Router();
var fs = require('fs');
var os = require('os');
var path = require('path');
var zlib = require('zlib');
var multer = require('multer');
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var cryptoUtil = require('../utils/crypto');
var { serverError } = require('../utils/errors');
// 25MB upload limit (same as transcribe). DiskStorage so 10 concurrent
// uploads don't pin 250MB of RAM at once — multer streams to a temp
// file as bytes arrive, the route reads from req.file.path. Identical
// user-visible perf (the upload is bottlenecked on the wire either
// way), much flatter memory profile.
var TMP_DIR = path.join(os.tmpdir(), 'pedscribe-audio-uploads');
try { fs.mkdirSync(TMP_DIR, { recursive: true }); } catch (e) {}
var upload = multer({
storage: multer.diskStorage({ destination: TMP_DIR }),
limits: { fileSize: 25 * 1024 * 1024 },
});
router.use(authMiddleware);
// ── POST save audio backup (compressed) ──────────────────────────────────
router.post('/audio-backups', upload.single('audio'), async function(req, res) {
console.log('[AudioBackup] POST received, hasFile=' + !!req.file + ', user=' + (req.user ? req.user.id : 'none'));
// Always remove the temp file when this handler returns — success or
// failure path. The Buffer-based version had no file to clean up;
// diskStorage means we own the lifecycle.
var tmpPath = req.file && req.file.path;
function cleanup() {
if (tmpPath) {
fs.unlink(tmpPath, function(unlinkErr) {
if (unlinkErr && unlinkErr.code !== 'ENOENT') console.warn('[AudioBackup] tmp cleanup failed:', unlinkErr.message);
});
tmpPath = null;
}
}
try {
if (!req.file) return res.status(400).json({ error: 'No audio file' });
var module = req.body.module || 'encounter';
var originalSize = req.file.size;
// Read the temp file once into a Buffer — gzip needs it in one shot
// anyway. For 25MB max this is a single, transient allocation per
// request instead of a persistent 25MB Buffer pinned by multer.
var raw = await fs.promises.readFile(tmpPath);
// Gzip compress, then AES-256-GCM encrypt the audio data.
var compressed = await new Promise(function(resolve, reject) {
zlib.gzip(raw, { level: 6 }, function(err, result) {
if (err) reject(err); else resolve(result);
});
});
var stored = cryptoUtil.encryptBuffer(compressed);
var result = await db.run(
'INSERT INTO audio_backups (user_id, module, mime_type, size_bytes, compressed_bytes, audio_data) VALUES ($1,$2,$3,$4,$5,$6)',
[req.user.id, module, req.file.mimetype || 'audio/webm', originalSize, stored.length, stored]
);
var ratio = originalSize > 0 ? Math.round((1 - compressed.length / originalSize) * 100) : 0;
console.log('[AudioBackup] Saved ' + (originalSize / 1024).toFixed(0) + 'KB → ' + (compressed.length / 1024).toFixed(0) + 'KB (' + ratio + '% compression) id=' + result.lastInsertRowid + ' user=' + req.user.id);
res.json({ success: true, id: result.lastInsertRowid, originalSize: originalSize, compressedSize: compressed.length });
} catch (e) {
return serverError(res, 'AudioBackup save', e, 'Could not save audio backup');
} finally {
cleanup();
}
});
// ── GET list audio backups ───────────────────────────────────────────────
router.get('/audio-backups', async function(req, res) {
try {
var rows = await db.all(
"SELECT id, module, mime_type, size_bytes, compressed_bytes, created_at, expires_at FROM audio_backups WHERE user_id = $1 AND expires_at > NOW() ORDER BY created_at DESC",
[req.user.id]
);
res.json({ success: true, backups: rows });
} catch (e) { return serverError(res, 'AudioBackup list', e, 'Could not list backups'); }
});
// ── GET download audio backup (decompressed) ─────────────────────────────
router.get('/audio-backups/:id/audio', async function(req, res) {
try {
var row = await db.get(
'SELECT audio_data, mime_type, size_bytes FROM audio_backups 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: 'Backup not found or expired' });
// Decrypt (if encrypted row) then gunzip. Legacy rows are passed through.
var ciphertext = row.audio_data;
var gzipped = cryptoUtil.isEncryptedBuffer(ciphertext) ? cryptoUtil.decryptBuffer(ciphertext) : ciphertext;
var decompressed = await new Promise(function(resolve, reject) {
zlib.gunzip(gzipped, function(err, result) {
if (err) reject(err); else resolve(result);
});
});
res.setHeader('Content-Type', row.mime_type || 'audio/webm');
res.setHeader('Content-Length', decompressed.length);
res.send(decompressed);
} catch (e) { return serverError(res, 'AudioBackup read', e, 'Could not retrieve audio backup'); }
});
// ── DELETE audio backup ──────────────────────────────────────────────────
router.delete('/audio-backups/:id', async function(req, res) {
try {
var result = await db.run(
'DELETE FROM audio_backups 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 });
} catch (e) { return serverError(res, 'AudioBackup delete', e, 'Could not delete backup'); }
});
module.exports = router;