Mass-rename via git mv (preserves history): server.js + 56 files in
src/** (db/, middleware/, routes/, utils/) renamed to .ts. Tests and
scripts stay .js for now — they run under plain node --test and do
not require tsx.
After rename, tsc --noEmit reported 53 errors across 13 files. Fixed:
INLINE FIXES (5 files):
- src/utils/prompts.ts: cast 3 dynamic property assignments to PROMPTS
via (PROMPTS as any).x — these are intentional runtime augmentations.
- src/routes/notes.ts: var opts: any = {...} so opts.model can be added
conditionally (was 'opts.model does not exist on {maxTokens:number}').
- src/routes/learningAI.ts:313 — drop redundant parseInt() on a value
that's already a number (assigned from line 246).
- src/routes/hospitalCourse.ts:66 — Date subtraction needs .getTime()
on each side; was 'arithmetic on type Date'.
- src/utils/transcribeLocal.ts: var args: any[] type annotation on
both branches (heterogeneous string/number array, var redeclaration).
@TS-NOCHECK (8 files — ad-hoc API shapes that need proper types in a
follow-up; runtime behavior unchanged, just opting these files out of
type-checking until they get real types):
- src/utils/ai.ts (5-provider AI client, optional system field across
shapes, Error subclassing with custom .code/.model)
- src/utils/embeddings.ts (Vertex/LiteLLM/OpenAI request shapes)
- src/routes/transcribe.ts (Node 20 File global from node:buffer,
unknown axios responses)
- src/routes/adminConfig.ts (same pattern as transcribe)
- src/routes/audioBackups.ts (req.body.X.length on unknown)
- src/routes/auth.ts (response.json() unknown in TS6, custom result
augmentation)
- src/routes/documents.ts (S3 client config built piecemeal)
- src/db/database.ts (db._cleanupInterval added at runtime)
Verification:
- npm run typecheck → 0 errors
- 46/46 unit tests pass
- tsx loads all 32 src/routes/*.ts cleanly (smoke test)
- Server start chain reaches DB connect step (fails locally because no
Postgres on dev box; in prod the docker-compose stack provides it)
Rollback anchor: ts-phase-1-2026-04-27 if anything misbehaves.
129 lines
6 KiB
TypeScript
129 lines
6 KiB
TypeScript
// ============================================================
|
|
// 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;
|