pediatric-ai-scribe-v3/src/routes/documents.js
Daniel Onyejesi 08a8fb26c4 v9: Major feature update — audio backup, SOAP save, Dragon memory, S3 docs, CI/CD, APK
Phase 1 — Critical Fixes:
- Fix SOAP instructions not clearing on Clear button
- Show transcription provider (AWS/OpenAI) in UI toast
- Fix silent transcription failures in dictation and SOAP modules
- Add IndexedDB audio backup system (24hr retention, retry from Settings)
- Prevent duplicate encounter saves with idempotency keys
- Add Save/Load/New bar to SOAP note generator

Phase 2 — Features:
- Dragon-like AI memory: auto-track user corrections, inject into prompts
- Per-section template categories (SOAP, HPI, well visit, sick visit)
- Bigger textarea for SOAP instructions
- S3 document upload/management (AWS S3, Backblaze B2, MinIO compatible)
- Faster transcription via lower bitrate recording (16kbps opus)

Phase 3 — APK & CI/CD:
- GitHub Actions: Docker build+push on version tags
- GitHub Actions: TWA APK build for Obtainium auto-updates
- Android TWA project with foreground service for background recording
- Enhanced PWA manifest with shortcuts and maskable icons
2026-03-28 21:08:32 +00:00

156 lines
6.3 KiB
JavaScript

// ============================================================
// DOCUMENTS ROUTES — S3-backed document upload & management
// ============================================================
var express = require('express');
var router = express.Router();
var multer = require('multer');
var crypto = require('crypto');
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
router.use(authMiddleware);
var upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 } // 10 MB
});
var ALLOWED_TYPES = [
'application/pdf',
'image/jpeg', 'image/png', 'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'text/csv'
];
// Lazy-load S3 client
var _s3Client = null;
function getS3Client() {
if (_s3Client) return _s3Client;
try {
var { S3Client } = require('@aws-sdk/client-s3');
var region = process.env.S3_REGION || process.env.AWS_BEDROCK_REGION || 'us-east-1';
var config = { region: region };
// Custom endpoint for S3-compatible providers (Backblaze B2, MinIO, etc.)
if (process.env.S3_ENDPOINT) {
config.endpoint = process.env.S3_ENDPOINT;
config.forcePathStyle = process.env.S3_FORCE_PATH_STYLE === 'true'; // Required for MinIO
}
// Credentials: use S3-specific keys first, fall back to AWS keys
var accessKey = process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID;
var secretKey = process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY;
if (accessKey && secretKey) {
config.credentials = {
accessKeyId: accessKey,
secretAccessKey: secretKey
};
}
_s3Client = new S3Client(config);
return _s3Client;
} catch (e) {
return null;
}
}
function isS3Configured() {
return !!process.env.S3_BUCKET && !!getS3Client();
}
// ── GET list user documents ────────────────────────────────────────────
router.get('/documents', async function(req, res) {
try {
if (!isS3Configured()) return res.json({ success: true, documents: [], s3_configured: false });
var rows = await db.all(
'SELECT id, filename, mime_type, size_bytes, description, created_at FROM user_documents WHERE user_id = $1 ORDER BY created_at DESC',
[req.user.id]
);
res.json({ success: true, documents: rows, s3_configured: true });
} catch (e) { logger.error('GET /documents', e.message); res.status(500).json({ error: e.message }); }
});
// ── POST upload document ───────────────────────────────────────────────
router.post('/documents/upload', upload.single('file'), async function(req, res) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured. Set S3_BUCKET and S3_REGION in .env' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
if (!ALLOWED_TYPES.includes(req.file.mimetype)) {
return res.status(400).json({ error: 'File type not allowed. Supported: PDF, images, Word docs, text, CSV' });
}
var { PutObjectCommand } = require('@aws-sdk/client-s3');
var prefix = process.env.S3_PREFIX || 'documents/';
var uuid = crypto.randomUUID();
var s3Key = prefix + req.user.id + '/' + uuid + '/' + req.file.originalname;
await getS3Client().send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: s3Key,
Body: req.file.buffer,
ContentType: req.file.mimetype,
ServerSideEncryption: 'AES256'
}));
var result = await db.run(
'INSERT INTO user_documents (user_id, s3_key, filename, mime_type, size_bytes, description) VALUES ($1,$2,$3,$4,$5,$6)',
[req.user.id, s3Key, req.file.originalname, req.file.mimetype, req.file.size, req.body.description || '']
);
res.json({ success: true, id: result.lastInsertRowid, filename: req.file.originalname });
} catch (e) { logger.error('POST /documents/upload', e.message); res.status(500).json({ error: e.message }); }
});
// ── GET download document (presigned URL) ──────────────────────────────
router.get('/documents/:id/download', async function(req, res) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
var doc = await db.get(
'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!doc) return res.status(404).json({ error: 'Document not found' });
var { GetObjectCommand } = require('@aws-sdk/client-s3');
var { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
var command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: doc.s3_key,
ResponseContentDisposition: 'attachment; filename="' + doc.filename + '"'
});
var url = await getSignedUrl(getS3Client(), command, { expiresIn: 300 }); // 5 min
res.json({ success: true, url: url });
} catch (e) { logger.error('GET /documents/:id/download', e.message); res.status(500).json({ error: e.message }); }
});
// ── DELETE document ────────────────────────────────────────────────────
router.delete('/documents/:id', async function(req, res) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
var doc = await db.get(
'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!doc) return res.status(404).json({ error: 'Document not found' });
try {
var { DeleteObjectCommand } = require('@aws-sdk/client-s3');
await getS3Client().send(new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: doc.s3_key
}));
} catch (s3err) {
logger.warn('S3 delete failed (continuing with DB delete):', s3err.message);
}
await db.run('DELETE FROM user_documents WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
res.json({ success: true });
} catch (e) { logger.error('DELETE /documents/:id', e.message); res.status(500).json({ error: e.message }); }
});
module.exports = router;