diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 6089e07..9e61c94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pediatric-ai-scribe", - "version": "6.52.0", + "version": "6.52.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pediatric-ai-scribe", - "version": "6.52.0", + "version": "6.52.1", "dependencies": { "@marp-team/marp-cli": "^4.3.1", "@marp-team/marp-core": "^4.3.0", @@ -28,6 +28,7 @@ "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "mammoth": "^1.8.0", + "marked": "^18.0.2", "multer": "^1.4.5-lts.1", "node-pg-migrate": "^7.7.0", "nodemailer": "^8.0.5", @@ -5698,6 +5699,18 @@ "integrity": "sha512-25GUs0yjS2hLl8zAemVndeEzThB1p42yxuDEKbd4JlL3jiz+jsm6e56Ya8B0VREOkNxLYB4TTwaoPJ3ElMmW+w==", "license": "MIT" }, + "node_modules/marked": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.2.tgz", + "integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index e15d743..efe0fea 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "mammoth": "^1.8.0", + "marked": "^18.0.2", "multer": "^1.4.5-lts.1", "node-pg-migrate": "^7.7.0", "nodemailer": "^8.0.5", diff --git a/public/js/notes.js b/public/js/notes.js index c678a00..d81775d 100644 --- a/public/js/notes.js +++ b/public/js/notes.js @@ -616,11 +616,29 @@ } function applyGeneratedNote(title, body) { - $('note-title').value = title; + var titleEl = $('note-title'); var container = $('note-body-editor'); - if (!container) return; - if (_editor) { _editor.destroy(); _editor = null; } - mountTiptap(container, body); + if (!titleEl || !container) { + console.error('[notes] applyGeneratedNote: editor not mounted; title/container missing'); + return; + } + titleEl.value = title || 'Voice note'; + + // Prefer Tiptap's native commands.setContent over a remount when an + // editor already exists — remounting on every voice take left the + // toolbar reattaching, which has caused flicker + a brief window + // where the body looked empty. + if (_editor && typeof _editor.commands === 'object') { + try { + _editor.commands.setContent(body || '
', { emitUpdate: true }); + _dirty = true; + return; + } catch (err) { + console.warn('[notes] Tiptap setContent failed, rebuilding:', err && err.message); + } + } + if (_editor) { try { _editor.destroy(); } catch (e) {} _editor = null; } + mountTiptap(container, body || ''); _dirty = true; } diff --git a/public/sw.js b/public/sw.js index bb31f1f..7f66d43 100644 --- a/public/sw.js +++ b/public/sw.js @@ -4,7 +4,7 @@ // API calls always fresh (critical for medical data accuracy) // ============================================================ -var CACHE_NAME = 'pedscribe-v12-notes5'; +var CACHE_NAME = 'pedscribe-v12-notes6'; var SHELL_ASSETS = [ '/', '/index.html', diff --git a/server.js b/server.js index fb07a3b..2dce218 100644 --- a/server.js +++ b/server.js @@ -363,6 +363,10 @@ function shutdown(signal) { // Close the Postgres pool so pending queries finish/reject cleanly. try { var dbMod = require('./src/db/database'); + // Clear the hourly cleanup interval first — otherwise its handle + // keeps the event loop alive past process.exit(0) and Docker + // ends up SIGKILL'ing us mid-shutdown at the 10s hard limit. + if (dbMod && dbMod._cleanupInterval) clearInterval(dbMod._cleanupInterval); if (dbMod && dbMod.pool && typeof dbMod.pool.end === 'function') { await dbMod.pool.end(); console.log('[shutdown] DB pool drained.'); diff --git a/src/db/database.js b/src/db/database.js index b759365..fab327c 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -464,7 +464,11 @@ async function cleanupExpired() { if (sess.rowCount > 0) console.log('[DB] Cleaned up ' + sess.rowCount + ' expired sessions'); } catch(e) { /* table may not exist yet */ } } -setInterval(cleanupExpired, 60 * 60 * 1000); // every hour (audio backups are 24h) +// Interval handle is exposed so server.js can clearInterval() it during +// graceful shutdown — otherwise Node's event loop stays alive past the +// 9-second shutdown deadline waiting for the next tick of this timer, +// and Docker SIGKILLs us mid-cleanup at the 10s hard limit. +var _cleanupInterval = setInterval(cleanupExpired, 60 * 60 * 1000); // every hour (audio backups are 24h) setTimeout(cleanupExpired, 10000); // also run 10s after startup // Query helpers @@ -513,4 +517,7 @@ function convertPlaceholders(sql) { }); } +// Expose the cleanup-interval handle so the shutdown path can clear it. +db._cleanupInterval = _cleanupInterval; + module.exports = db; diff --git a/src/routes/audioBackups.js b/src/routes/audioBackups.js index cb5d69e..3c0c1bb 100644 --- a/src/routes/audioBackups.js +++ b/src/routes/audioBackups.js @@ -5,6 +5,9 @@ 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'); @@ -12,23 +15,49 @@ var { authMiddleware } = require('../middleware/auth'); var cryptoUtil = require('../utils/crypto'); var { serverError } = require('../utils/errors'); -// 25MB upload limit (same as transcribe) -var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } }); +// 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(req.file.buffer, { level: 6 }, function(err, result) { + zlib.gzip(raw, { level: 6 }, function(err, result) { if (err) reject(err); else resolve(result); }); }); @@ -45,6 +74,8 @@ router.post('/audio-backups', upload.single('audio'), async function(req, res) { 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(); } }); diff --git a/src/routes/notes.js b/src/routes/notes.js index 51f5a89..a46b433 100644 --- a/src/routes/notes.js +++ b/src/routes/notes.js @@ -13,6 +13,28 @@ var logger = require('../utils/logger'); var cryptoUtil = require('../utils/crypto'); var { callAI } = require('../utils/ai'); var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); +var { marked } = require('marked'); + +// Convert AI output to clean HTML for Tiptap. Tiptap's setContent +// only handles HTML — markdown comes through as literal text. Real- +// world models ignore "output HTML only" prompts ~25% of the time +// and return mixed markdown/HTML. We normalise both: +// • Body looks like HTML (has tags) → return as-is. +// • Body looks like markdown OR plain text → run through marked. +function toHtmlBody(s) { + if (!s) return ''; + var t = String(s).trim(); + // Strip ```html / ``` code fences a model sometimes wraps the body in. + t = t.replace(/^```(?:html|markdown|md)?\s*/i, '').replace(/\s*```$/i, ''); + // Already HTML if there's a real tag. + if (/<(p|div|h[1-6]|ul|ol|li|strong|em|b|i|u|s|a|blockquote|code|pre|br|hr|span)[\s>]/i.test(t)) return t; + // Markdown → HTML. marked handles plain text fine — paragraphs become. + try { return marked.parse(t, { breaks: true }); } + catch (e) { + // Last-ditch: wrap the raw text in
so the editor at least shows it. + return '
' + t.replace(//g, '>').replace(/\n{2,}/g, '
').replace(/\n/g, '
') + '
' + transcript.replace(//g, '>').replace(/\n{2,}/g, '
').replace(/\n/g, '
') + '
so the user can edit it manually. + if (!body || !body.replace(/<[^>]+>/g, '').trim()) { + body = '
' + String(transcript).replace(//g, '>') + '
'; + } + if (!title) title = 'Voice note'; + res.json({ success: true, - title: (parsed.title || '').substring(0, 200), - body: parsed.body || '', + title: title.substring(0, 200), + body: body, model: result.model, }); logger.audit(req.user.id, 'note_from_voice', 'AI-generated note from voice', req, { category: 'notes' });