diff --git a/public/js/audioBackup.js b/public/js/audioBackup.js index e5c8e3f..33d35cc 100644 --- a/public/js/audioBackup.js +++ b/public/js/audioBackup.js @@ -1,13 +1,14 @@ // ============================================================ -// AUDIO BACKUP — IndexedDB-based audio recording backup -// Saves audio blobs locally so failed transcriptions can be retried +// AUDIO BACKUP — Server-side audio backup with local IndexedDB fallback +// Saves audio to server (gzip compressed in PostgreSQL), auto-deletes 24h. +// Falls back to IndexedDB if server save fails. // ============================================================ (function() { var DB_NAME = 'PedScribeAudioBackup'; var STORE_NAME = 'recordings'; var DB_VERSION = 1; - var MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + var MAX_AGE_MS = 24 * 60 * 60 * 1000; var _db = null; @@ -27,8 +28,43 @@ }); } - // Save audio blob with metadata + // Save audio — tries server first, falls back to IndexedDB window.saveAudioBackup = function(blob, module) { + // Try server save first + return saveToServer(blob, module).then(function(serverId) { + if (serverId) { + window._lastAudioBackupId = 'server_' + serverId; + return serverId; + } + // Fallback to IndexedDB + return saveToIndexedDB(blob, module); + }).catch(function() { + return saveToIndexedDB(blob, module); + }); + }; + + function saveToServer(blob, module) { + var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || ''; + if (!token) return Promise.resolve(null); + + var formData = new FormData(); + formData.append('audio', blob, 'audio.webm'); + formData.append('module', module || 'encounter'); + + return fetch('/api/audio-backups', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token }, + body: formData + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) return data.id; + return null; + }) + .catch(function() { return null; }); + } + + function saveToIndexedDB(blob, module) { return openDB().then(function(db) { return new Promise(function(resolve, reject) { var tx = db.transaction(STORE_NAME, 'readwrite'); @@ -41,38 +77,86 @@ mimeType: blob.type }; var req = store.add(record); - req.onsuccess = function() { resolve(req.result); }; // returns ID + req.onsuccess = function() { resolve(req.result); }; req.onerror = function() { reject(new Error('Failed to save audio backup')); }; }); }).then(function(id) { - window._lastAudioBackupId = id; - cleanupOldBackups(); + window._lastAudioBackupId = 'local_' + id; + cleanupOldLocalBackups(); return id; }).catch(function(err) { console.warn('[AudioBackup] Save failed:', err.message); return null; }); - }; + } - // Delete a specific backup + // Delete a specific backup (server or local) window.deleteAudioBackup = function(id) { + if (typeof id === 'string' && id.startsWith('server_')) { + var serverId = id.replace('server_', ''); + return fetch('/api/audio-backups/' + serverId, { + method: 'DELETE', + headers: getAuthHeaders() + }).then(function() {}).catch(function() {}); + } + // Local IndexedDB delete + var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : id; return openDB().then(function(db) { return new Promise(function(resolve) { var tx = db.transaction(STORE_NAME, 'readwrite'); - tx.objectStore(STORE_NAME).delete(id); + tx.objectStore(STORE_NAME).delete(localId); tx.oncomplete = function() { resolve(); }; tx.onerror = function() { resolve(); }; }); }).catch(function() {}); }; - // Get all backups (for UI listing) + // Get all backups (merged: server + local) window.getAudioBackups = function() { + var serverPromise = fetchServerBackups(); + var localPromise = getLocalBackups(); + + return Promise.all([serverPromise, localPromise]).then(function(results) { + var server = results[0].map(function(b) { + return { + id: 'server_' + b.id, + module: b.module, + timestamp: new Date(b.created_at).getTime(), + size: b.size_bytes, + compressedSize: b.compressed_bytes, + source: 'server', + expiresAt: b.expires_at + }; + }); + var local = results[1].map(function(b) { + return { + id: 'local_' + b.id, + module: b.module, + timestamp: b.timestamp, + size: b.size, + source: 'local' + }; + }); + return server.concat(local).sort(function(a, b) { return b.timestamp - a.timestamp; }); + }); + }; + + function fetchServerBackups() { + var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || ''; + if (!token) return Promise.resolve([]); + return fetch('/api/audio-backups', { + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } + }) + .then(function(r) { return r.json(); }) + .then(function(data) { return data.success ? (data.backups || []) : []; }) + .catch(function() { return []; }); + } + + function getLocalBackups() { return openDB().then(function(db) { return new Promise(function(resolve) { var tx = db.transaction(STORE_NAME, 'readonly'); - var store = tx.objectStore(STORE_NAME); - var req = store.getAll(); + var req = tx.objectStore(STORE_NAME).getAll(); req.onsuccess = function() { var records = (req.result || []).filter(function(r) { return (Date.now() - r.timestamp) < MAX_AGE_MS; @@ -82,14 +166,43 @@ req.onerror = function() { resolve([]); }; }); }).catch(function() { return []; }); - }; + } // Retry transcription from backup window.retryAudioBackup = function(id) { + if (typeof id === 'string' && id.startsWith('server_')) { + var serverId = id.replace('server_', ''); + showLoading('Downloading and re-transcribing...'); + var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || ''; + return fetch('/api/audio-backups/' + serverId + '/audio', { + headers: { 'Authorization': 'Bearer ' + token } + }) + .then(function(r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); }) + .then(function(blob) { + window._lastAudioBackupId = id; + return transcribeAudio(blob).then(function(data) { + hideLoading(); + if (data.success) { + showToast('Backup transcribed!', 'success'); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(data.text); + showToast('Transcript copied to clipboard', 'info'); + } + } else { + showToast('Retry failed: ' + (data.error || 'unknown'), 'error'); + } + return data; + }); + }) + .catch(function(err) { hideLoading(); showToast('Retry failed: ' + err.message, 'error'); }); + } + + // Local IndexedDB retry + var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : id; return openDB().then(function(db) { return new Promise(function(resolve, reject) { var tx = db.transaction(STORE_NAME, 'readonly'); - var req = tx.objectStore(STORE_NAME).get(id); + var req = tx.objectStore(STORE_NAME).get(localId); req.onsuccess = function() { if (!req.result) { reject(new Error('Backup not found')); return; } resolve(req.result); @@ -102,8 +215,7 @@ return transcribeAudio(record.blob).then(function(data) { hideLoading(); if (data.success) { - showToast('Backup transcribed successfully!', 'success'); - // Copy to clipboard + showToast('Backup transcribed!', 'success'); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(data.text); showToast('Transcript copied to clipboard', 'info'); @@ -116,8 +228,7 @@ }); }; - // Clean up old backups - function cleanupOldBackups() { + function cleanupOldLocalBackups() { openDB().then(function(db) { var tx = db.transaction(STORE_NAME, 'readwrite'); var store = tx.objectStore(STORE_NAME); @@ -127,10 +238,7 @@ var req = index.openCursor(range); req.onsuccess = function(e) { var cursor = e.target.result; - if (cursor) { - cursor.delete(); - cursor.continue(); - } + if (cursor) { cursor.delete(); cursor.continue(); } }; }).catch(function() {}); } @@ -141,7 +249,7 @@ if (!container) return; getAudioBackups().then(function(backups) { if (backups.length === 0) { - container.innerHTML = '

No audio backups. Recordings are saved here automatically and kept for 24 hours.

'; + container.innerHTML = '

No audio backups. Recordings are saved automatically to the server and kept for 24 hours.

'; return; } container.innerHTML = backups.map(function(b) { @@ -149,21 +257,25 @@ var sizeKb = Math.round(b.size / 1024); var age = Math.round((Date.now() - b.timestamp) / 60000); var ageStr = age < 60 ? age + 'm ago' : Math.round(age / 60) + 'h ago'; + var sourceTag = b.source === 'server' + ? 'server' + : 'local'; + var compInfo = b.compressedSize ? ' (' + Math.round(b.compressedSize / 1024) + ' KB compressed)' : ''; return '
' + '
' + - '
' + esc(b.module) + ' recording
' + - '
' + date.toLocaleString() + ' · ' + sizeKb + ' KB · ' + ageStr + '
' + + '
' + esc(b.module) + ' recording ' + sourceTag + '
' + + '
' + date.toLocaleString() + ' · ' + sizeKb + ' KB' + compInfo + ' · ' + ageStr + '
' + '
' + '' + '' + '
'; }).join(''); container.querySelectorAll('.audio-backup-retry').forEach(function(btn) { - btn.addEventListener('click', function() { retryAudioBackup(parseInt(btn.dataset.id)); }); + btn.addEventListener('click', function() { retryAudioBackup(btn.dataset.id); }); }); container.querySelectorAll('.audio-backup-delete').forEach(function(btn) { btn.addEventListener('click', function() { - deleteAudioBackup(parseInt(btn.dataset.id)).then(function() { + deleteAudioBackup(btn.dataset.id).then(function() { showToast('Backup deleted', 'info'); renderAudioBackups(); }); @@ -177,8 +289,5 @@ return String(str).replace(/&/g, '&').replace(//g, '>'); } - // Clean up on load - cleanupOldBackups(); - - console.log('Audio backup system loaded'); + cleanupOldLocalBackups(); })(); diff --git a/public/js/memories.js b/public/js/memories.js index a95bdee..0f5b056 100644 --- a/public/js/memories.js +++ b/public/js/memories.js @@ -87,20 +87,64 @@ } container.innerHTML = corrections.map(function(m) { var catLabel = CORRECTION_LABELS[m.category] || m.category; - var preview = (m.content || '').substring(0, 120).replace(/\n/g, ' '); - return '
' + - '
' + - '
' + - '' + esc(catLabel) + '' + - '
' + - '
' + esc(preview) + (m.content && m.content.length > 120 ? '...' : '') + '
' + + var preview = (m.name || '').replace(/\n/g, ' '); + // Parse original and corrected from content + var parts = parseCorrection(m.content || ''); + var date = m.created_at ? new Date(m.created_at).toLocaleDateString() : ''; + return '
' + + '
' + + '' + + '' + esc(catLabel) + '' + + '' + esc(preview) + '' + + '' + date + '' + + '' + + '
' + + '' + - '' + '
'; }).join(''); - container.querySelectorAll('.correction-delete-btn').forEach(function(btn) { - btn.addEventListener('click', function() { deleteMemory(parseInt(btn.dataset.id)); }); + + // Toggle expand/collapse + container.querySelectorAll('.correction-header').forEach(function(header) { + header.addEventListener('click', function(e) { + if (e.target.closest('.correction-delete-btn')) return; + var id = header.dataset.toggle; + var detail = document.getElementById('detail-' + id); + var arrow = document.getElementById('arrow-' + id); + if (detail) { + detail.classList.toggle('hidden'); + if (arrow) arrow.style.transform = detail.classList.contains('hidden') ? '' : 'rotate(90deg)'; + } + }); }); + + container.querySelectorAll('.correction-delete-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + deleteMemory(parseInt(btn.dataset.id)); + }); + }); + } + + function parseCorrection(content) { + var original = ''; + var corrected = ''; + var idx = content.indexOf('\nCORRECTED TO: '); + if (idx !== -1) { + original = content.substring(0, idx).replace(/^ORIGINAL:\s*/i, ''); + corrected = content.substring(idx + '\nCORRECTED TO: '.length); + } else { + original = content; + } + return { original: original.trim(), corrected: corrected.trim() }; } // ── Save memory ────────────────────────────────────────────────────────── diff --git a/server.js b/server.js index 479aece..09af021 100644 --- a/server.js +++ b/server.js @@ -167,6 +167,7 @@ app.use('/api', require('./src/routes/logs')); app.use('/api', require('./src/routes/encounters')); app.use('/api', require('./src/routes/memories')); app.use('/api', require('./src/routes/documents')); +app.use('/api', require('./src/routes/audioBackups')); app.use('/api', require('./src/routes/wellVisit')); app.use('/api', require('./src/routes/sickVisit')); app.use('/api/admin/learning', require('./src/routes/learningAI')); diff --git a/src/db/database.js b/src/db/database.js index 56a7257..ea25245 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -217,6 +217,23 @@ async function initDatabase() { // Add idempotency_key for duplicate prevention try { await client.query("ALTER TABLE saved_encounters ADD COLUMN IF NOT EXISTS idempotency_key TEXT"); } catch(e) {} try { await client.query("CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_enc_idemp ON saved_encounters(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL"); } catch(e) {} + // Audio backups table — server-side, auto-deleted after 24 hours + try { await client.query(` + CREATE TABLE IF NOT EXISTS audio_backups ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + module TEXT NOT NULL DEFAULT 'encounter', + mime_type TEXT DEFAULT 'audio/webm', + size_bytes INTEGER DEFAULT 0, + compressed_bytes INTEGER DEFAULT 0, + audio_data BYTEA NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours' + ); + CREATE INDEX IF NOT EXISTS idx_audio_backups_user ON audio_backups(user_id); + CREATE INDEX IF NOT EXISTS idx_audio_backups_expires ON audio_backups(expires_at); + `); } catch(e) {} + // User documents table for S3 storage try { await client.query(` CREATE TABLE IF NOT EXISTS user_documents ( @@ -270,15 +287,19 @@ async function initDatabase() { initDatabase(); -// Clean up expired saved encounters daily -async function cleanupExpiredEncounters() { +// Clean up expired saved encounters and audio backups +async function cleanupExpired() { try { - var result = await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()'); - if (result.rowCount > 0) console.log('[DB] Cleaned up ' + result.rowCount + ' expired encounters'); - } catch(e) { console.error('[DB] Cleanup error:', e.message); } + var enc = await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()'); + if (enc.rowCount > 0) console.log('[DB] Cleaned up ' + enc.rowCount + ' expired encounters'); + } catch(e) { console.error('[DB] Encounter cleanup error:', e.message); } + try { + var audio = await pool.query('DELETE FROM audio_backups WHERE expires_at < NOW()'); + if (audio.rowCount > 0) console.log('[DB] Cleaned up ' + audio.rowCount + ' expired audio backups'); + } catch(e) { /* table may not exist yet */ } } -setInterval(cleanupExpiredEncounters, 6 * 60 * 60 * 1000); // every 6 hours -setTimeout(cleanupExpiredEncounters, 10000); // also run 10s after startup +setInterval(cleanupExpired, 60 * 60 * 1000); // every hour (audio backups are 24h) +setTimeout(cleanupExpired, 10000); // also run 10s after startup // Query helpers var db = { diff --git a/src/routes/audioBackups.js b/src/routes/audioBackups.js new file mode 100644 index 0000000..a1c895c --- /dev/null +++ b/src/routes/audioBackups.js @@ -0,0 +1,93 @@ +// ============================================================ +// 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 zlib = require('zlib'); +var multer = require('multer'); +var db = require('../db/database'); +var { authMiddleware } = require('../middleware/auth'); + +// 25MB upload limit (same as transcribe) +var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } }); + +router.use(authMiddleware); + +// ── POST save audio backup (compressed) ────────────────────────────────── +router.post('/audio-backups', upload.single('audio'), async function(req, res) { + try { + if (!req.file) return res.status(400).json({ error: 'No audio file' }); + + var module = req.body.module || 'encounter'; + var originalSize = req.file.size; + + // Gzip compress the audio data + var compressed = await new Promise(function(resolve, reject) { + zlib.gzip(req.file.buffer, { level: 6 }, function(err, result) { + if (err) reject(err); else resolve(result); + }); + }); + + 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, compressed.length, compressed] + ); + + 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)'); + + res.json({ success: true, id: result.lastInsertRowid, originalSize: originalSize, compressedSize: compressed.length }); + } catch (e) { + console.error('[AudioBackup] Save error:', e.message); + res.status(500).json({ error: e.message }); + } +}); + +// ── 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) { res.status(500).json({ error: e.message }); } +}); + +// ── 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' }); + + // Decompress + var decompressed = await new Promise(function(resolve, reject) { + zlib.gunzip(row.audio_data, 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) { res.status(500).json({ error: e.message }); } +}); + +// ── 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) { res.status(500).json({ error: e.message }); } +}); + +module.exports = router;