Server-side audio backups with compression, viewable AI corrections
Audio Backups: - New audio_backups table in PostgreSQL (bytea, gzip compressed) - POST /api/audio-backups — upload with gzip compression (level 6) - GET /api/audio-backups — list user's backups - GET /api/audio-backups/:id/audio — download decompressed audio - DELETE /api/audio-backups/:id — delete backup - Auto-cleanup every hour (24h expiry) - Frontend saves to server first, falls back to IndexedDB - Settings shows source badge (server/local) per backup AI Corrections: - Corrections list is now expandable — click to view original vs corrected - Shows red "Original" and green "Corrected to" sections - Click arrow to expand/collapse each correction - Date shown on each correction
This commit is contained in:
parent
8d963ab7b5
commit
49b5a49999
5 changed files with 317 additions and 49 deletions
|
|
@ -1,13 +1,14 @@
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// AUDIO BACKUP — IndexedDB-based audio recording backup
|
// AUDIO BACKUP — Server-side audio backup with local IndexedDB fallback
|
||||||
// Saves audio blobs locally so failed transcriptions can be retried
|
// Saves audio to server (gzip compressed in PostgreSQL), auto-deletes 24h.
|
||||||
|
// Falls back to IndexedDB if server save fails.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
var DB_NAME = 'PedScribeAudioBackup';
|
var DB_NAME = 'PedScribeAudioBackup';
|
||||||
var STORE_NAME = 'recordings';
|
var STORE_NAME = 'recordings';
|
||||||
var DB_VERSION = 1;
|
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;
|
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) {
|
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 openDB().then(function(db) {
|
||||||
return new Promise(function(resolve, reject) {
|
return new Promise(function(resolve, reject) {
|
||||||
var tx = db.transaction(STORE_NAME, 'readwrite');
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
|
@ -41,38 +77,86 @@
|
||||||
mimeType: blob.type
|
mimeType: blob.type
|
||||||
};
|
};
|
||||||
var req = store.add(record);
|
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')); };
|
req.onerror = function() { reject(new Error('Failed to save audio backup')); };
|
||||||
});
|
});
|
||||||
}).then(function(id) {
|
}).then(function(id) {
|
||||||
window._lastAudioBackupId = id;
|
window._lastAudioBackupId = 'local_' + id;
|
||||||
cleanupOldBackups();
|
cleanupOldLocalBackups();
|
||||||
return id;
|
return id;
|
||||||
}).catch(function(err) {
|
}).catch(function(err) {
|
||||||
console.warn('[AudioBackup] Save failed:', err.message);
|
console.warn('[AudioBackup] Save failed:', err.message);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
// Delete a specific backup
|
// Delete a specific backup (server or local)
|
||||||
window.deleteAudioBackup = function(id) {
|
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 openDB().then(function(db) {
|
||||||
return new Promise(function(resolve) {
|
return new Promise(function(resolve) {
|
||||||
var tx = db.transaction(STORE_NAME, 'readwrite');
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
tx.objectStore(STORE_NAME).delete(id);
|
tx.objectStore(STORE_NAME).delete(localId);
|
||||||
tx.oncomplete = function() { resolve(); };
|
tx.oncomplete = function() { resolve(); };
|
||||||
tx.onerror = function() { resolve(); };
|
tx.onerror = function() { resolve(); };
|
||||||
});
|
});
|
||||||
}).catch(function() {});
|
}).catch(function() {});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get all backups (for UI listing)
|
// Get all backups (merged: server + local)
|
||||||
window.getAudioBackups = function() {
|
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 openDB().then(function(db) {
|
||||||
return new Promise(function(resolve) {
|
return new Promise(function(resolve) {
|
||||||
var tx = db.transaction(STORE_NAME, 'readonly');
|
var tx = db.transaction(STORE_NAME, 'readonly');
|
||||||
var store = tx.objectStore(STORE_NAME);
|
var req = tx.objectStore(STORE_NAME).getAll();
|
||||||
var req = store.getAll();
|
|
||||||
req.onsuccess = function() {
|
req.onsuccess = function() {
|
||||||
var records = (req.result || []).filter(function(r) {
|
var records = (req.result || []).filter(function(r) {
|
||||||
return (Date.now() - r.timestamp) < MAX_AGE_MS;
|
return (Date.now() - r.timestamp) < MAX_AGE_MS;
|
||||||
|
|
@ -82,14 +166,43 @@
|
||||||
req.onerror = function() { resolve([]); };
|
req.onerror = function() { resolve([]); };
|
||||||
});
|
});
|
||||||
}).catch(function() { return []; });
|
}).catch(function() { return []; });
|
||||||
};
|
}
|
||||||
|
|
||||||
// Retry transcription from backup
|
// Retry transcription from backup
|
||||||
window.retryAudioBackup = function(id) {
|
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 openDB().then(function(db) {
|
||||||
return new Promise(function(resolve, reject) {
|
return new Promise(function(resolve, reject) {
|
||||||
var tx = db.transaction(STORE_NAME, 'readonly');
|
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() {
|
req.onsuccess = function() {
|
||||||
if (!req.result) { reject(new Error('Backup not found')); return; }
|
if (!req.result) { reject(new Error('Backup not found')); return; }
|
||||||
resolve(req.result);
|
resolve(req.result);
|
||||||
|
|
@ -102,8 +215,7 @@
|
||||||
return transcribeAudio(record.blob).then(function(data) {
|
return transcribeAudio(record.blob).then(function(data) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('Backup transcribed successfully!', 'success');
|
showToast('Backup transcribed!', 'success');
|
||||||
// Copy to clipboard
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
navigator.clipboard.writeText(data.text);
|
navigator.clipboard.writeText(data.text);
|
||||||
showToast('Transcript copied to clipboard', 'info');
|
showToast('Transcript copied to clipboard', 'info');
|
||||||
|
|
@ -116,8 +228,7 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clean up old backups
|
function cleanupOldLocalBackups() {
|
||||||
function cleanupOldBackups() {
|
|
||||||
openDB().then(function(db) {
|
openDB().then(function(db) {
|
||||||
var tx = db.transaction(STORE_NAME, 'readwrite');
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
var store = tx.objectStore(STORE_NAME);
|
var store = tx.objectStore(STORE_NAME);
|
||||||
|
|
@ -127,10 +238,7 @@
|
||||||
var req = index.openCursor(range);
|
var req = index.openCursor(range);
|
||||||
req.onsuccess = function(e) {
|
req.onsuccess = function(e) {
|
||||||
var cursor = e.target.result;
|
var cursor = e.target.result;
|
||||||
if (cursor) {
|
if (cursor) { cursor.delete(); cursor.continue(); }
|
||||||
cursor.delete();
|
|
||||||
cursor.continue();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}).catch(function() {});
|
}).catch(function() {});
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +249,7 @@
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
getAudioBackups().then(function(backups) {
|
getAudioBackups().then(function(backups) {
|
||||||
if (backups.length === 0) {
|
if (backups.length === 0) {
|
||||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No audio backups. Recordings are saved here automatically and kept for 24 hours.</p>';
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No audio backups. Recordings are saved automatically to the server and kept for 24 hours.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = backups.map(function(b) {
|
container.innerHTML = backups.map(function(b) {
|
||||||
|
|
@ -149,21 +257,25 @@
|
||||||
var sizeKb = Math.round(b.size / 1024);
|
var sizeKb = Math.round(b.size / 1024);
|
||||||
var age = Math.round((Date.now() - b.timestamp) / 60000);
|
var age = Math.round((Date.now() - b.timestamp) / 60000);
|
||||||
var ageStr = age < 60 ? age + 'm ago' : Math.round(age / 60) + 'h ago';
|
var ageStr = age < 60 ? age + 'm ago' : Math.round(age / 60) + 'h ago';
|
||||||
|
var sourceTag = b.source === 'server'
|
||||||
|
? '<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--blue-light);color:var(--blue);">server</span>'
|
||||||
|
: '<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--g100);color:var(--g500);">local</span>';
|
||||||
|
var compInfo = b.compressedSize ? ' (' + Math.round(b.compressedSize / 1024) + ' KB compressed)' : '';
|
||||||
return '<div class="saved-enc-item" style="padding:8px 12px;">' +
|
return '<div class="saved-enc-item" style="padding:8px 12px;">' +
|
||||||
'<div style="flex:1;">' +
|
'<div style="flex:1;">' +
|
||||||
'<div style="font-weight:600;font-size:13px;">' + esc(b.module) + ' recording</div>' +
|
'<div style="font-weight:600;font-size:13px;display:flex;align-items:center;gap:6px;">' + esc(b.module) + ' recording ' + sourceTag + '</div>' +
|
||||||
'<div style="font-size:11px;color:var(--g500);">' + date.toLocaleString() + ' · ' + sizeKb + ' KB · ' + ageStr + '</div>' +
|
'<div style="font-size:11px;color:var(--g500);">' + date.toLocaleString() + ' · ' + sizeKb + ' KB' + compInfo + ' · ' + ageStr + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<button class="btn-sm btn-primary audio-backup-retry" data-id="' + b.id + '"><i class="fas fa-rotate-right"></i> Retry</button>' +
|
'<button class="btn-sm btn-primary audio-backup-retry" data-id="' + b.id + '"><i class="fas fa-rotate-right"></i> Retry</button>' +
|
||||||
'<button class="btn-sm btn-ghost audio-backup-delete" data-id="' + b.id + '" style="color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
'<button class="btn-sm btn-ghost audio-backup-delete" data-id="' + b.id + '" style="color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
container.querySelectorAll('.audio-backup-retry').forEach(function(btn) {
|
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) {
|
container.querySelectorAll('.audio-backup-delete').forEach(function(btn) {
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
deleteAudioBackup(parseInt(btn.dataset.id)).then(function() {
|
deleteAudioBackup(btn.dataset.id).then(function() {
|
||||||
showToast('Backup deleted', 'info');
|
showToast('Backup deleted', 'info');
|
||||||
renderAudioBackups();
|
renderAudioBackups();
|
||||||
});
|
});
|
||||||
|
|
@ -177,8 +289,5 @@
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up on load
|
cleanupOldLocalBackups();
|
||||||
cleanupOldBackups();
|
|
||||||
|
|
||||||
console.log('Audio backup system loaded');
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -87,20 +87,64 @@
|
||||||
}
|
}
|
||||||
container.innerHTML = corrections.map(function(m) {
|
container.innerHTML = corrections.map(function(m) {
|
||||||
var catLabel = CORRECTION_LABELS[m.category] || m.category;
|
var catLabel = CORRECTION_LABELS[m.category] || m.category;
|
||||||
var preview = (m.content || '').substring(0, 120).replace(/\n/g, ' ');
|
var preview = (m.name || '').replace(/\n/g, ' ');
|
||||||
return '<div class="mem-item" data-id="' + m.id + '">' +
|
// Parse original and corrected from content
|
||||||
'<div class="mem-item-info">' +
|
var parts = parseCorrection(m.content || '');
|
||||||
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
|
var date = m.created_at ? new Date(m.created_at).toLocaleDateString() : '';
|
||||||
'<span class="mem-item-cat">' + esc(catLabel) + '</span>' +
|
return '<div class="mem-item" style="flex-direction:column;align-items:stretch;cursor:pointer;" data-id="' + m.id + '">' +
|
||||||
'</div>' +
|
'<div style="display:flex;align-items:center;gap:8px;" class="correction-header" data-toggle="' + m.id + '">' +
|
||||||
'<div class="mem-item-preview">' + esc(preview) + (m.content && m.content.length > 120 ? '...' : '') + '</div>' +
|
'<i class="fas fa-chevron-right correction-arrow" id="arrow-' + m.id + '" style="font-size:10px;color:var(--g400);transition:transform 0.2s;"></i>' +
|
||||||
|
'<span class="mem-item-cat">' + esc(catLabel) + '</span>' +
|
||||||
|
'<span style="flex:1;font-size:12px;color:var(--g600);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(preview) + '</span>' +
|
||||||
|
'<span style="font-size:11px;color:var(--g400);flex-shrink:0;">' + date + '</span>' +
|
||||||
|
'<button class="btn-sm btn-ghost correction-delete-btn" data-id="' + m.id + '" style="color:var(--red);flex-shrink:0;" title="Delete"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="correction-detail hidden" id="detail-' + m.id + '" style="margin-top:8px;padding:8px 12px;background:var(--g50);border-radius:6px;font-size:12px;line-height:1.6;">' +
|
||||||
|
'<div style="margin-bottom:8px;">' +
|
||||||
|
'<div style="font-weight:600;color:var(--red);font-size:11px;text-transform:uppercase;margin-bottom:2px;">Original (AI generated):</div>' +
|
||||||
|
'<div style="color:var(--g600);white-space:pre-wrap;font-family:inherit;">' + esc(parts.original) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div>' +
|
||||||
|
'<div style="font-weight:600;color:var(--green);font-size:11px;text-transform:uppercase;margin-bottom:2px;">Corrected to:</div>' +
|
||||||
|
'<div style="color:var(--g800);white-space:pre-wrap;font-family:inherit;">' + esc(parts.corrected) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<button class="btn-sm btn-ghost correction-delete-btn" data-id="' + m.id + '" style="color:var(--red);" title="Delete"><i class="fas fa-trash"></i></button>' +
|
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).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 ──────────────────────────────────────────────────────────
|
// ── Save memory ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,7 @@ app.use('/api', require('./src/routes/logs'));
|
||||||
app.use('/api', require('./src/routes/encounters'));
|
app.use('/api', require('./src/routes/encounters'));
|
||||||
app.use('/api', require('./src/routes/memories'));
|
app.use('/api', require('./src/routes/memories'));
|
||||||
app.use('/api', require('./src/routes/documents'));
|
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/wellVisit'));
|
||||||
app.use('/api', require('./src/routes/sickVisit'));
|
app.use('/api', require('./src/routes/sickVisit'));
|
||||||
app.use('/api/admin/learning', require('./src/routes/learningAI'));
|
app.use('/api/admin/learning', require('./src/routes/learningAI'));
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,23 @@ async function initDatabase() {
|
||||||
// Add idempotency_key for duplicate prevention
|
// 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("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) {}
|
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
|
// User documents table for S3 storage
|
||||||
try { await client.query(`
|
try { await client.query(`
|
||||||
CREATE TABLE IF NOT EXISTS user_documents (
|
CREATE TABLE IF NOT EXISTS user_documents (
|
||||||
|
|
@ -270,15 +287,19 @@ async function initDatabase() {
|
||||||
|
|
||||||
initDatabase();
|
initDatabase();
|
||||||
|
|
||||||
// Clean up expired saved encounters daily
|
// Clean up expired saved encounters and audio backups
|
||||||
async function cleanupExpiredEncounters() {
|
async function cleanupExpired() {
|
||||||
try {
|
try {
|
||||||
var result = await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()');
|
var enc = await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()');
|
||||||
if (result.rowCount > 0) console.log('[DB] Cleaned up ' + result.rowCount + ' expired encounters');
|
if (enc.rowCount > 0) console.log('[DB] Cleaned up ' + enc.rowCount + ' expired encounters');
|
||||||
} catch(e) { console.error('[DB] Cleanup error:', e.message); }
|
} 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
|
setInterval(cleanupExpired, 60 * 60 * 1000); // every hour (audio backups are 24h)
|
||||||
setTimeout(cleanupExpiredEncounters, 10000); // also run 10s after startup
|
setTimeout(cleanupExpired, 10000); // also run 10s after startup
|
||||||
|
|
||||||
// Query helpers
|
// Query helpers
|
||||||
var db = {
|
var db = {
|
||||||
|
|
|
||||||
93
src/routes/audioBackups.js
Normal file
93
src/routes/audioBackups.js
Normal file
|
|
@ -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;
|
||||||
Loading…
Reference in a new issue