pediatric-ai-scribe-v3/src/routes/extensions.js
Daniel cc76c66953
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
fix: the signed-out preview never worked, because /api was gated wholesale
Eleven routers are mounted on '/api' and called router.use(authMiddleware) with
no path. Mounted that way, the gate applies to every /api request that reaches
the router — including routes belonging to routers mounted further down
server.js. extensions.js did it from line 295; the assistant is mounted at 305.
So a signed-out request to /api/clinical-assistant/status was refused ten lines
before the preview middleware could look at it, whatever the admin setting said.
server.js line 250 already warned about this shape.

Each gate now names its own prefix, so a router protects its own routes and
nothing else. Verified afterwards that every namespace which must stay shut
still answers 401 signed out: extensions, encounters, memories, notes, diagrams,
generated images, image jobs, documents, audio backups, ED encounters,
don't-miss, patient education, billing, well visit, admin, transcribe and the
rest. Two of these routers were gating routes nobody realised they were gating.

Second defect in the same path: authMiddleware only ever looks for a token, so
calling it unconditionally after the preview identity had been assigned rejected
exactly the requests preview exists to serve. Only that identity may skip it;
authMiddleware stays strict everywhere else.

Preview now answers with a real cited answer, and stays as narrow as it was
designed to be — four allow-listed paths, no identity, nothing ownable. A test
now walks every /api router and fails on a blanket gate, which is how the last
six were found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 02:00:59 +02:00

301 lines
12 KiB
JavaScript

// ============================================================
// PHONE EXTENSIONS / PAGERS — per-user personal directory
// ============================================================
// Soft-delete via trashed_at: deleting moves to trash; user can restore
// or purge. List supports async search (q) and trash toggle (trash=1).
var express = require('express');
var multer = require('multer');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var transfer = require('../utils/extensionTransfer');
var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 1024 * 1024 } });
// Scoped to this router's own prefix. These routers are mounted on /api, so a
// bare router.use(authMiddleware) gated every /api path — including routes
// belonging to routers mounted after it. That is what kept the signed-out
// assistant preview returning 401 no matter what the admin setting said.
router.use('/extensions', authMiddleware);
function sanitizeType(t) {
return transfer.sanitizeType(t);
}
function clip(s, n) {
return transfer.clip(s, n);
}
function parseId(raw) {
var n = parseInt(raw, 10);
return (Number.isFinite(n) && n > 0) ? n : null;
}
// ── GET /api/extensions ─────────────────────────────────────
// Query params:
// trash=1 → return trashed items only (default: active only)
// q=text → case-insensitive substring search on location/name/number/notes
router.get('/extensions', async function (req, res) {
try {
var trashMode = req.query.trash === '1' || req.query.trash === 'true';
var q = (req.query.q || '').trim();
var sql =
'SELECT id, location, name, number, type, notes, trashed_at, created_at, updated_at ' +
'FROM user_phone_extensions WHERE user_id = $1 AND trashed_at IS ' +
(trashMode ? 'NOT NULL' : 'NULL');
var params = [req.user.id];
if (q) {
sql += ' AND (LOWER(location) LIKE $2 OR LOWER(name) LIKE $2 OR LOWER(number) LIKE $2 OR LOWER(notes) LIKE $2)';
params.push('%' + q.toLowerCase() + '%');
}
sql += ' ORDER BY location ASC, type ASC, name ASC';
var rows = await db.all(sql, params);
res.json({ success: true, items: rows });
} catch (e) {
logger.error('GET /extensions', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── GET /api/extensions/export — ZIP export for sharing/import ─────────
router.get('/extensions/export', async function (req, res) {
try {
var rows = await db.all(
'SELECT location, name, number, type, notes FROM user_phone_extensions WHERE user_id = $1 AND trashed_at IS NULL ORDER BY location ASC, type ASC, name ASC',
[req.user.id]
);
var payload = transfer.exportPayload(rows);
var zip = transfer.createJsonZip('pedscribe-extensions.json', payload);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="pedscribe-extensions.zip"');
res.setHeader('X-Export-Count', String(payload.items.length));
res.send(zip);
} catch (e) {
logger.error('GET /extensions/export', e.message);
res.status(500).json({ error: 'Export failed' });
}
});
// ── POST /api/extensions/import/preview — preview another user's JSON export
router.post('/extensions/import/preview', async function (req, res) {
try {
var items = transfer.importItemsFromBody(req.body);
if (!items.length) return res.status(400).json({ error: 'No valid entries to import' });
var analysis = await analyzeImportForUser(req.user.id, items);
res.json({ success: true, preview: analysis });
} catch (e) {
logger.error('POST /extensions/import/preview', e.message);
res.status(500).json({ error: 'Import preview failed' });
}
});
// ── POST /api/extensions/import — import another user's JSON export ─────
router.post('/extensions/import', async function (req, res) {
try {
var items = transfer.importItemsFromBody(req.body);
var result = await importItemsForUser(req.user.id, items, parseImportOptions(req.body && req.body.options));
if (!result.success) return res.status(400).json(result);
res.json(result);
} catch (e) {
logger.error('POST /extensions/import', e.message);
res.status(500).json({ error: 'Import failed' });
}
});
// ── POST /api/extensions/import-file/preview — preview JSON or ZIP file ─
router.post('/extensions/import-file/preview', upload.single('file'), async function (req, res) {
try {
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'Import file is required' });
var parsed = transfer.parseImportBuffer(req.file.buffer);
var items = transfer.importItemsFromBody(parsed);
if (!items.length) return res.status(400).json({ error: 'No valid entries to import' });
var analysis = await analyzeImportForUser(req.user.id, items);
res.json({ success: true, preview: analysis });
} catch (e) {
logger.error('POST /extensions/import-file/preview', e.message);
res.status(400).json({ error: 'Import file must be a valid PedScribe JSON or ZIP export' });
}
});
// ── POST /api/extensions/import-file — import JSON or ZIP file ─────────
router.post('/extensions/import-file', upload.single('file'), async function (req, res) {
try {
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'Import file is required' });
var parsed = transfer.parseImportBuffer(req.file.buffer);
var items = transfer.importItemsFromBody(parsed);
var result = await importItemsForUser(req.user.id, items, parseImportOptions(req.body));
if (!result.success) return res.status(400).json(result);
res.json(result);
} catch (e) {
logger.error('POST /extensions/import-file', e.message);
res.status(400).json({ error: 'Import file must be a valid PedScribe JSON or ZIP export' });
}
});
function parseImportOptions(raw) {
raw = raw || {};
return {
importPossibleDuplicates: raw.importPossibleDuplicates === true || raw.importPossibleDuplicates === 'true' || raw.importPossibleDuplicates === '1',
restoreTrashed: raw.restoreTrashed === true || raw.restoreTrashed === 'true' || raw.restoreTrashed === '1'
};
}
async function getExistingRows(userId) {
return db.all(
'SELECT id, location, name, number, type, notes, trashed_at FROM user_phone_extensions WHERE user_id = $1',
[userId]
);
}
async function analyzeImportForUser(userId, items) {
var existingRows = await getExistingRows(userId);
return transfer.analyzeImport(items, existingRows);
}
async function importItemsForUser(userId, items, options) {
if (!items.length) return { success: false, error: 'No valid entries to import' };
options = options || {};
var existingRows = await getExistingRows(userId);
var analysis = transfer.analyzeImport(items, existingRows);
var imported = 0;
var skipped = 0;
var restored = 0;
var possibleSkipped = 0;
for (var i = 0; i < analysis.entries.length; i++) {
var entry = analysis.entries[i];
var item = entry.item;
if (entry.status === 'exact_active') {
skipped++;
continue;
}
if (entry.status === 'exact_trashed') {
if (options.restoreTrashed && entry.exactMatch && entry.exactMatch.id) {
await db.run(
'UPDATE user_phone_extensions SET trashed_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2 AND trashed_at IS NOT NULL',
[entry.exactMatch.id, userId]
);
restored++;
} else {
skipped++;
}
continue;
}
if (entry.status === 'possible_duplicate' && !options.importPossibleDuplicates) {
skipped++;
possibleSkipped++;
continue;
}
await db.run(
'INSERT INTO user_phone_extensions (user_id, location, name, number, type, notes) VALUES ($1,$2,$3,$4,$5,$6)',
[userId, item.location, item.name, item.number, item.type, item.notes]
);
imported++;
}
return { success: true, imported: imported, restored: restored, skipped: skipped, possibleSkipped: possibleSkipped, total: items.length, preview: analysis.summary };
}
// ── POST /api/extensions ────────────────────────────────────
router.post('/extensions', async function (req, res) {
try {
var location = clip(req.body.location, 120);
var name = clip(req.body.name, 120);
var number = clip(req.body.number, 40);
var type = sanitizeType(req.body.type);
var notes = clip(req.body.notes, 500);
if (!location) return res.status(400).json({ error: 'Location is required' });
if (!name) return res.status(400).json({ error: 'Name / department is required' });
if (!number) return res.status(400).json({ error: 'Number is required' });
var result = await db.run(
'INSERT INTO user_phone_extensions (user_id, location, name, number, type, notes) VALUES ($1,$2,$3,$4,$5,$6)',
[req.user.id, location, name, number, type, notes]
);
res.json({ success: true, id: result.lastInsertRowid });
} catch (e) {
logger.error('POST /extensions', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── PUT /api/extensions/:id ─────────────────────────────────
router.put('/extensions/:id', async function (req, res) {
try {
var id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
var location = clip(req.body.location, 120);
var name = clip(req.body.name, 120);
var number = clip(req.body.number, 40);
var type = sanitizeType(req.body.type);
var notes = clip(req.body.notes, 500);
if (!location || !name || !number) return res.status(400).json({ error: 'Location, name, and number are required' });
var result = await db.run(
'UPDATE user_phone_extensions SET location=$1, name=$2, number=$3, type=$4, notes=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7',
[location, name, number, type, notes, id, req.user.id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ success: true });
} catch (e) {
logger.error('PUT /extensions/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── DELETE /api/extensions/:id — soft-delete (move to trash) ──
router.delete('/extensions/:id', async function (req, res) {
try {
var id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
var result = await db.run(
'UPDATE user_phone_extensions SET trashed_at = NOW(), updated_at = NOW() WHERE id=$1 AND user_id=$2 AND trashed_at IS NULL',
[id, req.user.id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Not found or already trashed' });
res.json({ success: true });
} catch (e) {
logger.error('DELETE /extensions/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── POST /api/extensions/:id/restore — bring back from trash ──
router.post('/extensions/:id/restore', async function (req, res) {
try {
var id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
var result = await db.run(
'UPDATE user_phone_extensions SET trashed_at = NULL, updated_at = NOW() WHERE id=$1 AND user_id=$2 AND trashed_at IS NOT NULL',
[id, req.user.id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Not found in trash' });
res.json({ success: true });
} catch (e) {
logger.error('POST /extensions/:id/restore', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── DELETE /api/extensions/:id/purge — hard delete (only from trash) ──
router.delete('/extensions/:id/purge', async function (req, res) {
try {
var id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
var result = await db.run(
'DELETE FROM user_phone_extensions WHERE id=$1 AND user_id=$2 AND trashed_at IS NOT NULL',
[id, req.user.id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Must trash before purge' });
res.json({ success: true });
} catch (e) {
logger.error('DELETE /extensions/:id/purge', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;
module.exports._test = { importItemsForUser, analyzeImportForUser, parseImportOptions };