pediatric-ai-scribe-v3/src/routes/adminConfig.js
2026-09-12 21:00:22 +02:00

1174 lines
57 KiB
JavaScript

// ============================================================
// ADMIN CONFIG ROUTES — CMS for announcements, prompts, emails, flags
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware, adminMiddleware } = require('../middleware/auth');
var PROMPTS = require('../utils/prompts');
var promptCatalog = require('../utils/promptCatalog');
var promptRevisions = require('../utils/promptRevisions');
var { conversationBudget, conversationLimit } = require('../utils/clinicalConversation');
var logger = require('../utils/logger');
var { gatewayUrl, serverError } = require('../utils/errors');
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm');
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getLiteLLMEmbeddingModels } = require('../utils/embeddings');
router.use(authMiddleware);
var lockdown = require('../utils/adminLockdown');
// One gate rather than a check in every write route, because that list grows
// and a new route added later would quietly miss it. Reads always pass —
// lockdown hides nothing, it only refuses changes.
//
// Under lockdown a write is refused unless it is day-to-day operation:
// - invitations, which is running the service, not configuring it;
// - the "test" endpoints, which send a probe and persist nothing;
// - /config/:key, which decides per key — some keys stay editable, and that
// route applies lockdown.isLocked() itself.
// Everything else — model policy, SMTP, prompts, resets — is configuration.
var OPERATIONAL_WRITE = /^\/invites(\/|$)|\/test(-email)?$|^\/config\/[^/]+$/;
router.use(function(req, res, next) {
if (!lockdown.enabled() || req.method === 'GET' || req.method === 'HEAD') return next();
if (OPERATIONAL_WRITE.test(req.path)) return next();
return res.status(403).json({ error: lockdown.refusal(req.path.replace(/^\/config\//, '')) });
});
function liteLLMBaseUrl() {
return (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
}
function searchTerms(search) {
return String(search || '').toLowerCase().split(/[\s,]+/).map(function(term) { return term.trim(); }).filter(Boolean);
}
function matchesDiscoverySearch(item, search, extra) {
var terms = searchTerms(search);
if (terms.length === 0) return true;
var haystack = [item.id, item.name, item.source, item.kind, item.mode, item.capability, item.dims, extra].filter(Boolean).join(' ').toLowerCase();
return terms.some(function(term) { return haystack.indexOf(term) !== -1; });
}
function liteLLMModelId(model) {
return model && (model.model_name || model.id) ? (model.model_name || model.id) : '';
}
function liteLLMModelMode(model) {
return model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : '';
}
// Can this model be shown a picture? Three answers, not two: true, false, and
// "the gateway does not say". Only an explicit false is actionable — a model
// the gateway has no metadata for is not thereby proven blind, and refusing
// those would block most of the roster over missing metadata rather than over
// a real incapability.
async function liteLLMVisionSupport(modelId) {
if (!modelId || !process.env.LITELLM_API_BASE) return null;
try {
var axios = require('axios');
var resp = await axios.get(liteLLMBaseUrl() + '/model/info',
{ headers: getLiteLLMAdminHeaders(), timeout: 10000 });
var models = (resp.data && resp.data.data) || [];
for (var i = 0; i < models.length; i++) {
if (liteLLMModelId(models[i]) !== modelId) continue;
var info = models[i].model_info || {};
return typeof info.supports_vision === 'boolean' ? info.supports_vision : null;
}
return null;
} catch (e) {
// The gateway being unreachable is not evidence about the model.
return null;
}
}
async function probeLiteLLMEmbeddingDimensions(modelId) {
try {
var axios = require('axios');
var resp = await axios.post(gatewayUrl('/embeddings'), {
model: modelId,
input: 'dimension probe'
}, { headers: getLiteLLMHeaders('application/json'), timeout: 30000 });
var embedding = resp.data && resp.data.data && resp.data.data[0] && resp.data.data[0].embedding;
return Array.isArray(embedding) ? embedding.length : '?';
} catch (e) {
logger.warn('LiteLLM embedding dimension probe failed for ' + modelId + ': ' + e.message);
return '?';
}
}
// ── GET announcement (any authenticated user) ──────────────────────────────
router.get('/config/announcement', async function(req, res) {
try {
var enabled = await db.getSetting('announcement.enabled');
var text = await db.getSetting('announcement.text');
var type = await db.getSetting('announcement.type');
res.json({
success: true,
enabled: enabled === 'true',
text: text || '',
type: type || 'info'
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
router.use(adminMiddleware);
// ── GET all config entries ─────────────────────────────────────────────────
router.get('/config', async function(req, res) {
try {
var budget = conversationBudget(process.env);
var rows = await db.all(
"SELECT key, value, updated_at FROM app_settings ORDER BY key",
[]
);
// Also include current in-memory prompts (may differ if not yet in DB)
var promptKeys = PROMPTS.getAllPrompts().map(function(p) { return p.key; });
var inDb = new Set(rows.filter(function(r) { return r.key.startsWith('prompt.'); }).map(function(r) { return r.key; }));
promptKeys.forEach(function(key) {
var dbKey = 'prompt.' + key;
if (!inDb.has(dbKey)) {
rows.push({ key: dbKey, value: PROMPTS[key], updated_at: null });
}
});
res.json({ success: true, config: rows, conversationBudget: budget, lockdown: lockdown.state() });
} catch (e) { res.status(e.statusCode || 500).json({ error: 'Request failed' }); }
});
// ── Clinical assistant starter prompt pool ─────────────────────────────────
router.get('/clinical-assistant/prompt-pool', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool status failed' });
}
});
router.get('/clinical-assistant/prompt-pool/snapshots', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(req.query.limit || 20);
res.json({ success: true, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool snapshot listing failed' });
}
});
router.post('/clinical-assistant/prompt-pool/regenerate', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var examples = await clinicalAssistant.refreshPromptPool(true, req.user && req.user.id);
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, count: examples.length, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool regeneration failed' });
}
});
router.post('/clinical-assistant/prompt-pool/restore', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var id = Number(req.body && req.body.id);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Snapshot id is required' });
var payload = await clinicalAssistant.restorePromptPoolSnapshot(id, req.user && req.user.id);
if (!payload) return res.status(404).json({ error: 'Snapshot not found' });
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, count: Array.isArray(payload.examples) ? payload.examples.length : 0, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool restore failed' });
}
});
// ── POST send test email ───────────────────────────────────────────────────
router.post('/config/test-email', async function(req, res) {
try {
var { to, template } = req.body;
if (!to) return res.status(400).json({ error: 'Recipient email required' });
var subjectKey = 'email.' + (template || 'verify') + '.subject';
var bodyKey = 'email.' + (template || 'verify') + '.body';
var subject = await db.getSetting(subjectKey) || 'Test email from Pediatric AI Scribe';
var bodyText = await db.getSetting(bodyKey) || 'This is a test email.';
var sendEmail = require('./auth').__sendEmail;
if (!sendEmail) {
return res.status(400).json({ error: 'Email not configured (SMTP_HOST missing)' });
}
var emailWrapper = require('./auth').__emailWrapper;
var btnHtml = require('./auth').__btnHtml;
var html = emailWrapper(
'<p style="margin:0 0 6px;font-size:18px;font-weight:600;color:#111827;">Test Email</p>' +
'<p style="color:#6b7280;margin:12px 0 20px;line-height:1.6;font-size:14px;">' + bodyText.replace(/\n/g, '<br>') + '</p>' +
btnHtml('#', 'Test Button')
);
var ok = await sendEmail(to, '[TEST] ' + subject, html);
res.json({ success: ok, message: ok ? 'Test email sent to ' + to : 'SMTP not configured' });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── Global prompt catalogue and immutable revision history ─────────────────
router.get('/config/prompts', async function(req, res) {
try {
res.json({ success: true, prompts: await promptRevisions.list(db) });
} catch (e) { promptRevisions.respondError(res, e); }
});
router.get('/config/prompts/:key/history', async function(req, res) {
try {
res.json(Object.assign({ success: true }, await promptRevisions.history(db, req.params.key, req.query.limit)));
} catch (e) { promptRevisions.respondError(res, e); }
});
router.get('/config/prompts/:key/revisions/:id', async function(req, res) {
try {
res.json({ success: true, revision: await promptRevisions.read(db, req.params.key, req.params.id) });
} catch (e) { promptRevisions.respondError(res, e); }
});
async function changePrompt(req, res, action, key) {
try {
var result = await promptRevisions.mutate(db, key, {
action: action, value: req.body.value, expectedRevision: req.body.expectedRevision,
revisionId: req.body.revisionId, actor: req.user.id
});
logger.audit(req.user.id, 'admin_prompt_' + action, 'Updated global prompt: ' + key, req, { category: 'admin' });
res.json(Object.assign({ success: true }, result));
} catch (e) { promptRevisions.respondError(res, e); }
}
router.post('/config/prompts/:key/reset', function(req, res) {
return changePrompt(req, res, 'reset', req.params.key);
});
router.post('/config/prompts/:key/restore', function(req, res) {
return changePrompt(req, res, 'restore', req.params.key);
});
// ── POST reset all non-prompt settings to defaults ──────────────────────
router.post('/config/reset-defaults', async function(req, res) {
try {
var defaults = [
['registration_enabled', 'true'],
['announcement.enabled', 'false'],
['announcement.text', ''],
['announcement.type', 'info'],
['feature.read_aloud', 'true'],
['feature.nextcloud', 'true'],
['feature.memories', 'true'],
['email.verify.subject', 'Verify your Pediatric AI Scribe account'],
['email.verify.body', 'Thank you for signing up! Click the button below to verify your email address. This link expires in 24 hours.'],
['email.reset.subject', 'Reset your password — Pediatric AI Scribe'],
['email.reset.body', 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed — your password has not been changed.'],
['site.name', 'Pediatric AI Scribe'],
['site.auto_delete_days', '7'],
];
for (var d = 0; d < defaults.length; d++) {
await db.setSetting(defaults[d][0], defaults[d][1]);
}
logger.audit(req.user.id, 'admin_config_reset_all', 'Reset all settings to defaults', req, { category: 'admin' });
res.json({ success: true, message: 'Settings reset to defaults (SMTP and custom models preserved)' });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── GET SMTP status ──────────────────────────────────────────────────────
router.get('/config/smtp/status', async function(req, res) {
try {
var host = await db.getSetting('smtp.host') || process.env.SMTP_HOST || '';
var user = await db.getSetting('smtp.user') || process.env.SMTP_USER || '';
var from = await db.getSetting('smtp.from') || process.env.SMTP_FROM || process.env.SMTP_USER || '';
var port = await db.getSetting('smtp.port') || process.env.SMTP_PORT || '587';
// Never return password
res.json({
success: true,
configured: !!host,
host: host,
port: port,
user: user,
from: from,
source: process.env.SMTP_HOST ? 'env' : (host ? 'database' : 'none')
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── PUT update SMTP settings ─────────────────────────────────────────────
// NOTE: This must come BEFORE the wildcard PUT /config/:key(*) below
router.put('/config/smtp', async function(req, res) {
try {
var { host, port, user, pass, from, secure } = req.body;
if (!host) return res.status(400).json({ error: 'SMTP host required' });
await db.setSetting('smtp.host', host.trim());
await db.setSetting('smtp.port', String(port || '587').trim());
await db.setSetting('smtp.user', (user || '').trim());
await db.setSetting('smtp.from', (from || user || '').trim());
await db.setSetting('smtp.secure', String(secure === true || secure === 'true'));
if (pass && pass.trim()) {
await db.setSetting('smtp.pass', pass.trim());
}
logger.audit(req.user.id, 'admin_smtp_update', 'Updated SMTP settings', req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── DELETE SMTP settings (clear DB override, fall back to env) ───────────
router.delete('/config/smtp', async function(req, res) {
try {
var keys = ['smtp.host', 'smtp.port', 'smtp.user', 'smtp.pass', 'smtp.from', 'smtp.secure'];
for (var k of keys) {
await db.run('DELETE FROM app_settings WHERE key = $1', [k]);
}
logger.audit(req.user.id, 'admin_smtp_clear', 'Cleared SMTP DB overrides', req, { category: 'admin' });
res.json({ success: true, message: 'SMTP DB settings cleared (env vars still apply if set)' });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ============================================================
// MODEL ROUTES — All must come BEFORE the wildcard PUT /config/:key(*)
// The wildcard uses :key(*) which matches slashes, so it would intercept
// /config/models/toggle and /config/models/default if registered after it.
// ============================================================
// ── GET models config ────────────────────────────────────────────────────
router.get('/config/models', async function(req, res) {
try {
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, activeProvider } = require('../utils/models');
var providerModels;
switch (activeProvider) {
case 'bedrock': providerModels = BEDROCK_MODELS; break;
case 'azure': providerModels = AZURE_MODELS; break;
case 'litellm': providerModels = []; break; // LiteLLM: no built-ins — use discover
default: providerModels = OPENROUTER_MODELS;
}
var disabledRaw = await db.getSetting('models.disabled') || '[]';
var customRaw = await db.getSetting('models.custom') || '[]';
var defaultModel = await require('../utils/models').getEffectiveDefaultModel(db);
var disabled, custom;
disabled = JSON.parse(disabledRaw);
if (!Array.isArray(disabled)) throw new Error('Invalid disabled model settings');
custom = JSON.parse(customRaw);
if (!Array.isArray(custom)) throw new Error('Invalid custom model settings');
custom = custom.map(function(m) { return Object.assign({}, m, { enabled: !disabled.includes(m.id) }); });
var models = providerModels.map(function(m) {
return Object.assign({}, m, { enabled: !disabled.includes(m.id) });
});
res.json({
success: true,
provider: activeProvider,
models: models,
custom: custom,
defaultModel: defaultModel,
litellmHint: activeProvider === 'litellm'
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── PUT toggle model enabled/disabled ────────────────────────────────────
router.put('/config/models/toggle', async function(req, res) {
try {
var { modelId, enabled } = req.body;
if (typeof modelId !== 'string' || !modelId.trim() || typeof enabled !== 'boolean') return res.status(400).json({ error: 'modelId and boolean enabled required' });
var modelPolicy = require('../utils/models');
var roster = modelPolicy.getAvailableModels().concat(JSON.parse(await db.getSetting('models.custom') || '[]'));
if (!roster.some(function(m) { return m.id === modelId; })) return res.status(400).json({ error: 'Unknown model' });
var disabledRaw = await db.getSetting('models.disabled') || '[]';
var disabled;
disabled = JSON.parse(disabledRaw);
if (!Array.isArray(disabled)) throw new Error('Invalid disabled model settings');
if (enabled) {
disabled = disabled.filter(function(id) { return id !== modelId; });
} else {
if (!disabled.includes(modelId)) disabled.push(modelId);
}
await db.setSetting('models.disabled', JSON.stringify(disabled));
// Disabling is as final as removing, from a caller's point of view: the
// model stops being selectable, so it stops being allowed.
if (!enabled) await forgetModelEverywhere(modelId);
await require('../utils/models').reconcileDefaultModel(db);
logger.audit(req.user.id, 'admin_model_toggle', (enabled ? 'Enabled' : 'Disabled') + ' model: ' + modelId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── PUT set default model ─────────────────────────────────────────────────
router.put('/config/models/default', async function(req, res) {
try {
var { modelId } = req.body;
if (typeof modelId !== 'string' || !modelId.trim()) return res.status(400).json({ error: 'modelId required' });
var models = await require('../utils/models').getAvailableModelsWithOverrides(db);
if (!models.some(function(m) { return m.id === modelId.trim(); })) return res.status(400).json({ error: 'Default model must be enabled' });
await db.setSetting('models.default', modelId.trim());
logger.audit(req.user.id, 'admin_model_default', 'Set default model: ' + modelId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST add custom model (manual entry) ─────────────────────────────────
router.post('/config/models/custom', async function(req, res) {
try {
var { id, name } = req.body;
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
var trimmedId = id.trim();
if (trimmedId.length > 200) return res.status(400).json({ error: 'Model ID too long (max 200 chars)' });
if (!/^[a-zA-Z0-9._\-\/\:]+$/.test(trimmedId)) {
return res.status(400).json({ error: 'Invalid model ID format. Use only letters, numbers, dots, dashes, slashes, and colons.' });
}
// Check if model ID conflicts with a built-in model (all providers)
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS } = require('../utils/models');
var allBuiltIn = [].concat(OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS);
if (allBuiltIn.find(function(m) { return m.id === trimmedId; })) {
return res.status(400).json({ error: 'Model ID conflicts with a built-in model. Use the toggle to enable/disable built-in models.' });
}
var customRaw = await db.getSetting('models.custom') || '[]';
var custom;
custom = JSON.parse(customRaw);
if (!Array.isArray(custom)) throw new Error('Invalid custom model settings');
var existing = custom.find(function(m) { return m.id === trimmedId; });
custom = custom.filter(function(m) { return m.id !== trimmedId; });
custom.push({ id: trimmedId, name: name.trim().substring(0, 100) });
await db.setSetting('models.custom', JSON.stringify(custom));
logger.audit(req.user.id, existing ? 'admin_model_update' : 'admin_model_add', (existing ? 'Updated' : 'Added') + ' custom model: ' + trimmedId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── DELETE custom model ───────────────────────────────────────────────────
// A model that leaves the roster must leave every list that names it. Otherwise
// clinical_assistant.allowed_models keeps offering an id the gateway no longer
// has, and the only sign is a failed request at the moment someone asks a
// clinical question. The lists are advisory copies of the roster; the roster is
// the fact.
async function forgetModelEverywhere(ids) {
var gone = (Array.isArray(ids) ? ids : [ids]).filter(Boolean);
if (!gone.length) return;
for (var key of ['clinical_assistant.allowed_models',
'clinical_assistant.allowed_image_models',
'clinical_assistant.image_model_roster']) {
var current = String(await db.getSetting(key, '') || '');
if (!current) continue;
var kept = current.split(',').map(function (id) { return id.trim(); })
.filter(function (id) { return id && gone.indexOf(id) === -1; });
if (kept.join(',') !== current) await db.setSetting(key, kept.join(','));
}
// A default that no longer exists is reconciled on the next read, but
// clearing it here means the admin panel never shows it as current.
var current = String(await db.getSetting('models.default', '') || '');
if (current && gone.indexOf(current) !== -1) await db.setSetting('models.default', '');
}
router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
try {
var modelId = req.params.modelId;
var customRaw = await db.getSetting('models.custom') || '[]';
var custom;
custom = JSON.parse(customRaw);
if (!Array.isArray(custom)) throw new Error('Invalid custom model settings');
custom = custom.filter(function(m) { return m.id !== modelId; });
await db.setSetting('models.custom', JSON.stringify(custom));
await forgetModelEverywhere(modelId);
await require('../utils/models').reconcileDefaultModel(db);
logger.audit(req.user.id, 'admin_model_delete', 'Removed custom model: ' + modelId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST clear all custom/discovered models ───────────────────────────────
router.post('/config/models/clear-all', async function(req, res) {
try {
await db.setSetting('models.custom', '[]');
await db.setSetting('models.disabled', '[]');
await db.setSetting('models.default', '');
// Nothing is on the roster any more, so nothing may remain allowed.
for (var key of ['clinical_assistant.allowed_models',
'clinical_assistant.allowed_image_models',
'clinical_assistant.image_model_roster']) {
await db.setSetting(key, '');
}
logger.audit(req.user.id, 'admin_models_clear_all', 'Cleared all custom models and disabled list', req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── GET discover models from provider API ─────────────────────────────────
// Modes that are definitely not a chat model. The filter excludes what the
// gateway says is something else, rather than requiring it to say 'chat': a
// model with no metadata is unknown, not disqualified, and requiring a positive
// 'chat' would hide every model the gateway has no mode for.
var NON_CHAT_MODES = ['image_generation', 'rerank', 'audio_speech', 'audio_transcription',
'embedding', 'moderation'];
async function nonChatModelIds() {
if (!process.env.LITELLM_API_BASE) return new Set();
try {
var axios = require('axios');
var resp = await axios.get(liteLLMBaseUrl() + '/model/info',
{ headers: getLiteLLMAdminHeaders(), timeout: 10000 });
var out = new Set();
((resp.data && resp.data.data) || []).forEach(function (m) {
var id = liteLLMModelId(m);
if (id && NON_CHAT_MODES.indexOf(liteLLMModelMode(m)) !== -1) out.add(id);
});
return out;
} catch (e) {
// No metadata means no filtering, which is what it did before.
return new Set();
}
}
router.get('/config/models/discover', async function(req, res) {
try {
var { discoverModels } = require('../utils/ai');
var discovered = await discoverModels();
// /v1/models carries no mode, so rerankers, image and speech models all
// arrived in a list meant for choosing a chat model. /model/info does carry
// it, which is where every other discovery endpoint already looks.
var exclude = await nonChatModelIds();
discovered = discovered.filter(function (m) { return !exclude.has(m.id); });
var search = (req.query.q || '').toLowerCase().trim();
if (search) {
discovered = discovered.filter(function(m) {
return m.id.toLowerCase().indexOf(search) !== -1 || m.name.toLowerCase().indexOf(search) !== -1;
});
}
res.json({ success: true, models: discovered, count: discovered.length });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST add discovered model to custom list ──────────────────────────────
router.post('/config/models/add-discovered', async function(req, res) {
try {
var { id, name } = req.body;
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
var trimmedId = id.trim();
if (trimmedId.length > 200) return res.status(400).json({ error: 'Model ID too long (max 200 chars)' });
var customRaw = await db.getSetting('models.custom') || '[]';
var custom;
custom = JSON.parse(customRaw);
if (!Array.isArray(custom)) throw new Error('Invalid custom model settings');
custom = custom.filter(function(m) { return m.id !== trimmedId; });
custom.push({ id: trimmedId, name: name.trim().substring(0, 100) });
await db.setSetting('models.custom', JSON.stringify(custom));
logger.audit(req.user.id, 'admin_model_discover_add', 'Added discovered model: ' + trimmedId, req, { category: 'admin' });
res.json({ success: true, id: trimmedId });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST test a model with a simple prompt ────────────────────────────────
router.post('/config/models/test', async function(req, res) {
try {
var { modelId } = req.body;
if (!modelId) return res.status(400).json({ error: 'modelId required' });
var { callAI } = require('../utils/ai');
var start = Date.now();
var result = await callAI(
[{ role: 'user', content: 'Reply with only the word: OK' }],
{ model: modelId, maxTokens: 20, temperature: 0, skipAllowlistCheck: true }
);
res.json({
success: true,
response: (result.content || '').trim(),
model: result.model,
provider: result.provider,
duration: Date.now() - start,
tokens: result.usage
});
} catch (e) {
res.json({ success: false, error: e.message });
}
});
// ── GET discover image-capable models from provider ───────────────────────
router.get('/config/image-models/discover', async function(req, res) {
try {
var axios = require('axios');
var discovered = [];
if (process.env.LITELLM_API_BASE) {
var resp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 15000 });
var models = (resp.data && resp.data.data) || [];
models.forEach(function(m) {
if (liteLLMModelMode(m) === 'image_generation') {
var id = liteLLMModelId(m);
if (id) discovered.push({ id: id, name: id, source: 'gateway-api', mode: 'image_generation', capability: 'image' });
}
});
}
var search = (req.query.q || '').toLowerCase().trim();
if (search) discovered = discovered.filter(function(m) { return matchesDiscoverySearch(m, search, 'image generation'); });
res.json({ success: true, models: discovered, count: discovered.length });
} catch (e) {
res.status(500).json({ error: e.message || 'Request failed' });
}
});
// ── POST test image generation model ──────────────────────────────────────
router.post('/config/image-models/test', async function(req, res) {
try {
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LiteLLM not configured' });
var modelId = String(req.body.modelId || '').trim();
if (!modelId) return res.status(400).json({ error: 'modelId required' });
var axios = require('axios');
var started = Date.now();
var imgResp = await axios.post(gatewayUrl('/images/generations'), {
model: modelId,
prompt: 'A simple child-friendly icon of lungs and an inhaler, flat medical illustration, no text',
size: '1024x1024'
}, { headers: getLiteLLMHeaders('application/json'), timeout: 120000 });
var item = imgResp.data && imgResp.data.data && imgResp.data.data[0] ? imgResp.data.data[0] : {};
res.json({
success: true,
model: modelId,
duration: Date.now() - started,
imageUrl: item.url || null,
base64: item.b64_json || null
});
} catch (e) {
var detail = e.response && e.response.data ? JSON.stringify(e.response.data).substring(0, 500) : e.message;
res.json({ success: false, error: detail });
}
});
// ── GET TTS provider status, voice list, and DB overrides ────────────────
router.get('/config/tts', async function(req, res) {
try {
var envProvider = getTTSEnvProvider();
var activeProvider = getTTSProvider();
var dbVoice = await db.getSetting('tts.voice') || '';
var dbModel = await db.getSetting('tts.model') || '';
var envVoice = process.env.LITELLM_TTS_VOICE || '';
var envModel = process.env.LITELLM_TTS_MODEL || '';
var currentModel = dbModel || envModel;
var voices = getLiteLLMTTSVoicesForModel(currentModel, { currentVoice: dbVoice });
var currentVoice = [dbVoice, envVoice, voices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(currentModel, voice);
}) || '';
res.json({
success: true,
provider: activeProvider,
envProvider: envProvider,
currentVoice: currentVoice,
currentModel: currentModel,
dbVoice: dbVoice,
dbModel: dbModel,
envVoice: envVoice,
envModel: envModel,
configured: {
litellm: !!process.env.LITELLM_API_BASE
},
voices: {
litellm: voices
}
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── GET discover TTS voices from provider ────────────────────────────────
router.get('/config/tts/discover', async function(req, res) {
try {
var search = (req.query.q || '').toLowerCase().trim();
var axios = require('axios');
var discovered = [];
var provider = getTTSProvider();
if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
var dbVoice = await db.getSetting('tts.voice') || '';
var dbModel = await db.getSetting('tts.model') || '';
var currentVoice = dbVoice || process.env.LITELLM_TTS_VOICE || '';
var currentModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
var modelInfo = [];
try {
var lResp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 });
modelInfo = lResp.data && lResp.data.data ? lResp.data.data : [];
} catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); }
getLiteLLMTTSDiscoveryItems(modelInfo, { currentModel: currentModel, currentVoice: currentVoice }).forEach(function(item) {
discovered.push(item);
});
}
if (search) {
discovered = discovered.filter(function(d) {
return d.id.toLowerCase().indexOf(search) !== -1 || d.name.toLowerCase().indexOf(search) !== -1;
});
}
res.json({ success: true, provider: provider, voices: discovered, count: discovered.length });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST test TTS — returns base64 audio ─────────────────────────────────
router.post('/config/tts/test', async function(req, res) {
try {
var text = ((req.body.text || 'Hello, this is a TTS test for Pediatric AI Scribe.')).substring(0, 500);
var voice = req.body.voice;
var axios = require('axios');
var provider = getTTSProvider();
if (provider === 'none') return res.json({ success: false, error: 'No TTS provider configured' });
if (provider !== 'litellm') return res.json({ success: false, error: 'TTS is configured for LiteLLM only' });
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
var adminModel = await db.getSetting('tts.model') || '';
var adminVoice = await db.getSetting('tts.voice') || '';
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
var usedVoice = [voice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(candidate) {
return isLiteLLMTTSVoiceCompatible(ttsModel, candidate);
}) || '';
if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' });
var payload = Object.assign({ model: ttsModel, voice: usedVoice, input: text }, getLiteLLMTTSRequestOptions(ttsModel));
var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
payload,
{ headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 }
);
var buffer = Buffer.from(ttsResp.data);
res.json({ success: true, audio: buffer.toString('base64'), provider: provider, voice: usedVoice });
} catch (e) {
var detail = e.response && e.response.data
? (Buffer.isBuffer(e.response.data) ? e.response.data.toString('utf8').substring(0, 300) : JSON.stringify(e.response.data).substring(0, 300))
: e.message;
res.json({ success: false, error: detail });
}
});
// ── GET STT provider status and model list ─────────────────────────────────
router.get('/config/stt', async function(req, res) {
try {
var envProvider = process.env.TRANSCRIBE_PROVIDER || 'auto';
var configured = getSTTDependencies();
var activeProvider = getSTTProvider(configured);
var dbModel = await db.getSetting('stt.model') || '';
var envModel = process.env.LITELLM_STT_MODEL || '';
res.json({
success: true,
provider: activeProvider,
envProvider: envProvider,
currentModel: dbModel || envModel,
dbModel: dbModel,
envModel: envModel,
configured: configured,
models: getSTTModelLists()
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── Citation quality ─────────────────────────────────────────────────────
// Answers whose citations pointed at nothing. Read-only, and it survives
// lockdown because it is quality tracking, not configuration.
router.get('/citation-audit', async function(req, res) {
try {
var rows = await db.all(
"SELECT a.id, a.question, a.cited_count, a.source_count, a.unverifiable, " +
" a.source_titles, a.created_at, u.email AS user_email " +
"FROM citation_audit a LEFT JOIN users u ON u.id = a.user_id " +
"WHERE a.expires_at > NOW() ORDER BY a.created_at DESC LIMIT 100", []
);
var totals = await db.get(
"SELECT COUNT(*)::int AS answers, COALESCE(SUM(array_length(unverifiable, 1)), 0)::int AS markers " +
"FROM citation_audit WHERE expires_at > NOW()", []
);
res.json({ success: true, rows: rows, totals: totals || { answers: 0, markers: 0 } });
} catch (e) { return serverError(res, 'Citation audit', e, 'Could not read citation quality'); }
});
// ── Registration invites ─────────────────────────────────────────────────
// A code is shown once, at creation. Only its hash and last four characters
// are stored, so this endpoint is the only time it can be read.
var invites = require('../utils/registrationInvites');
router.get('/invites', async function(req, res) {
try {
// The code itself, decrypted for display, so an invitation can be copied
// again rather than only at the moment it was made. The cipher never leaves
// the server. A row created before codes were kept simply has no code, and
// its four-character hint is all there is to show.
var rows = (await invites.list()).map(function (row) {
var code = invites.decryptCode(row.code_cipher);
delete row.code_cipher;
return Object.assign(row, { code: code });
});
res.json({ success: true, invites: rows, inviteOnly: await invites.inviteOnly(), lockdown: lockdown.state() });
} catch (e) { return serverError(res, 'Invites list', e, 'Could not list invitations'); }
});
router.post('/invites', async function(req, res) {
try {
var created = await invites.create(req.user.id, { days: req.body.days, note: req.body.note });
logger.audit(req.user.id, 'invite_create', 'Created a registration invite valid ' + created.days + ' days', req, { category: 'admin' });
res.json({ success: true, code: created.code, days: created.days });
} catch (e) { return serverError(res, 'Invite create', e, 'Could not create an invitation'); }
});
router.post('/invites/:id/revoke', async function(req, res) {
try {
var done = await invites.revoke(req.params.id, req.user.id);
if (!done) return res.status(400).json({ error: 'Only an unused, unrevoked invitation can be revoked' });
logger.audit(req.user.id, 'invite_revoke', 'Revoked invitation ' + req.params.id, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { return serverError(res, 'Invite revoke', e, 'Could not revoke the invitation'); }
});
// Clearing away spent invitations — used, or expired without being used. A code
// that could still be redeemed is never deleted: that would take it off the list
// without taking it out of anybody's inbox, leaving nothing to say who held it.
// Revoke is what stops a live code, and it leaves the row behind, marked.
router.delete('/invites/spent', async function(req, res) {
try {
var removed = await invites.removeSpent();
logger.audit(req.user.id, 'invite_delete', 'Cleared ' + removed + ' spent invitations', req, { category: 'admin' });
res.json({ success: true, removed: removed });
} catch (e) { return serverError(res, 'Invite clear', e, 'Could not clear spent invitations'); }
});
router.delete('/invites/:id', async function(req, res) {
try {
if (!await invites.remove(req.params.id)) {
// Said plainly rather than as "not found": the row is very likely there,
// and the reason it cannot go is worth knowing.
return res.status(409).json({ error: 'That invitation can still be used. Revoke it instead.' });
}
logger.audit(req.user.id, 'invite_delete', 'Deleted invitation ' + req.params.id, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { return serverError(res, 'Invite delete', e, 'Could not delete the invitation'); }
});
// ── GET discover STT models from provider ────────────────────────────────
router.get('/config/stt/discover', async function(req, res) {
try {
var search = (req.query.q || '').toLowerCase().trim();
var axios = require('axios');
var discovered = [];
var provider = getSTTProvider();
if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
try {
var sResp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 });
getLiteLLMSTTModels(sResp.data && sResp.data.data).forEach(function(id) {
discovered.push({ id: id, name: id, source: 'gateway-api' });
});
} catch(e) { logger.warn('STT discovery failed: ' + e.message); }
if (discovered.length === 0) {
getSTTModelLists().litellm.forEach(function(id) {
discovered.push({ id: id, name: id, source: 'configured-default' });
});
}
}
if (search) {
discovered = discovered.filter(function(d) {
return d.id.toLowerCase().indexOf(search) !== -1 || d.name.toLowerCase().indexOf(search) !== -1;
});
}
res.json({ success: true, provider: provider, models: discovered, count: discovered.length });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST test STT — accepts base64 audio, returns transcription ───────────
router.post('/config/stt/test', async function(req, res) {
try {
var { audioBase64, mimeType } = req.body;
if (!audioBase64) return res.json({ success: false, error: 'audioBase64 required' });
var audioBuffer = Buffer.from(audioBase64, 'base64');
var mime = mimeType || 'audio/webm';
var provider = getSTTProvider();
if (provider === 'none') return res.json({ success: false, error: 'No STT provider configured' });
if (provider !== 'litellm') return res.json({ success: false, error: 'STT is configured for LiteLLM only' });
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
var start = Date.now();
var text = '';
var adminSttModel = await db.getSetting('stt.model') || '';
var sttModel = adminSttModel || process.env.LITELLM_STT_MODEL || '';
if (!sttModel) return res.json({ success: false, error: 'No LiteLLM STT model configured' });
var ext = mime.split('/')[1] || 'webm';
var file = new File([audioBuffer], 'audio.' + ext, { type: mime });
var form = new FormData();
form.append('file', file);
form.append('model', sttModel);
var sttResp = await fetch(gatewayUrl('/audio/transcriptions'), {
method: 'POST', headers: getLiteLLMHeaders(), body: form
});
if (!sttResp.ok) throw new Error('LiteLLM transcription failed (HTTP ' + sttResp.status + ')');
var sttData = await sttResp.json();
if (!sttData || typeof sttData.text !== 'string') throw new Error('Invalid transcription response');
text = sttData.text;
res.json({ success: true, text: text.trim(), provider: provider, duration: Date.now() - start });
} catch (e) {
res.json({ success: false, error: e.message });
}
});
// ── GET embedding model config ────────────────────────────────────────────
router.get('/config/embeddings', async function(req, res) {
try {
var { isEmbeddingsAvailable, DEFAULT_MODEL, DEFAULT_DIMS } = require('../utils/embeddings');
var dbModel = await db.getSetting('embeddings.model') || '';
var dbDims = await db.getSetting('embeddings.dimensions') || '';
var envModel = process.env.EMBEDDING_MODEL || DEFAULT_MODEL;
var envDims = parseInt(process.env.EMBEDDING_DIMENSIONS) || DEFAULT_DIMS;
var provider = 'none';
if (process.env.LITELLM_API_BASE) provider = 'litellm';
res.json({
success: true,
provider: provider,
configured: isEmbeddingsAvailable(),
currentModel: dbModel || envModel,
currentDimensions: dbDims ? parseInt(dbDims) : envDims,
dbModel: dbModel,
dbDimensions: dbDims,
envModel: envModel,
envDimensions: envDims,
models: []
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── GET discover embedding models from provider ───────────────────────────
router.get('/config/embeddings/discover', async function(req, res) {
try {
var search = (req.query.q || '').toLowerCase().trim();
var axios = require('axios');
var discovered = [];
var provider = 'none';
if (process.env.LITELLM_API_BASE) provider = 'litellm';
if (provider === 'litellm') {
try {
var eResp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 });
var embeddingModels = getLiteLLMEmbeddingModels(eResp.data && eResp.data.data);
for (var i = 0; i < embeddingModels.length; i++) {
var m = embeddingModels[i];
var dims = m.dims === '?' ? await probeLiteLLMEmbeddingDimensions(m.id) : m.dims;
discovered.push({ id: m.id, name: m.name, dims: dims, source: 'gateway-api', mode: 'embedding', capability: 'embedding' });
}
} catch(e) { logger.warn('LiteLLM embedding metadata discovery failed: ' + e.message); }
}
if (search) {
discovered = discovered.filter(function(d) {
return matchesDiscoverySearch(d, search, 'embedding vector');
});
}
res.json({ success: true, provider: provider, models: discovered, count: discovered.length });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── POST test embedding ───────────────────────────────────────────────────
router.post('/config/embeddings/test', async function(req, res) {
try {
var text = (req.body.text || 'Pediatric patient with fever').substring(0, 500);
var { generateEmbedding, DEFAULT_MODEL } = require('../utils/embeddings');
var db = require('../db/database');
var dbModel = await db.getSetting('embeddings.model') || '';
var start = Date.now();
var vector = await generateEmbedding(text);
var dims = Array.isArray(vector) ? vector.length : 0;
var sample = Array.isArray(vector) ? vector.slice(0, 8).map(function(v) { return v.toFixed(4); }) : [];
res.json({
success: true,
dimensions: dims,
sample: sample,
model: dbModel || process.env.EMBEDDING_MODEL || DEFAULT_MODEL,
duration: Date.now() - start
});
} catch (e) {
res.json({ success: false, error: e.message });
}
});
// ============================================================
// WILDCARD CONFIG — Must come AFTER all specific model routes above
// :key(*) matches slashes, so it would intercept /config/models/toggle
// and /config/models/default if registered before them.
// ============================================================
// ── PUT update a single config entry ──────────────────────────────────────
router.put('/config/:key(*)', async function(req, res) {
try {
var key = req.params.key;
var value = req.body.value;
if (value === undefined || value === null) {
return res.status(400).json({ error: 'value is required' });
}
// Security: only allow known key prefixes
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'registration_invite_only', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.', 'clinical_assistant.', 'my_resources.'];
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
if (!isAllowed) {
return res.status(400).json({ error: 'Unknown config key' });
}
// Some settings stay editable under lockdown; the rest are refused here,
// whatever the UI showed.
if (lockdown.isLocked(key)) {
return res.status(403).json({ error: lockdown.refusal(key) });
}
// Model policy mutations must use the validated model endpoints.
if (key.startsWith('models.')) return res.status(400).json({ error: 'Use the model configuration endpoints' });
if (key.startsWith('feature.') && !['true', 'false'].includes(String(value))) return res.status(400).json({ error: 'Feature value must be true or false' });
if (key === 'clinical_assistant.conversation_chars' && value !== '' && value != null) {
// One validator for the whole budget: parseInt would have accepted
// "120000abc". Empty stays legal and means "use the environment".
try {
conversationLimit(value);
} catch (_) {
return res.status(400).json({ error: 'Conversation budget must be an integer between 1000 and 1000000 UTF-16 code units, or empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS' });
}
}
// The slide reviewer is shown rendered images of the deck. A text-only model
// cannot do that job: it would be sent pictures and fail at request time, on
// every generation, with the administrator having had no warning at the one
// moment they could have chosen differently. Refused only when the gateway
// states supports_vision === false; an unknown is left to the administrator,
// which is how it worked before there was any check at all.
if ((key === 'my_resources.review_model' || key === 'clinical_assistant.vision_model')
&& String(value).trim()) {
var canSee = await liteLLMVisionSupport(String(value).trim());
if (canSee === false) {
return res.status(400).json({
error: String(value).trim() + ' is a text-only model, so it cannot be shown ' +
'an image. Choose a model the gateway reports as vision-capable.'
});
}
}
if (key === 'clinical_assistant.preview_enabled' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: 'Preview mode must be true or false' });
}
if (key === 'clinical_assistant.show_sources' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: 'Show sources must be true or false' });
}
if (key === 'registration_invite_only' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: 'Invite-only must be true or false' });
}
// The admin's image-model roster: comma-separated gateway model ids.
if (key === 'clinical_assistant.image_model_roster') {
var rosterIds = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
if (rosterIds.length > 100 || rosterIds.some(function(id) { return id.length > 200 || /[\s<>"'`]/.test(id); })) {
return res.status(400).json({ error: 'Image model list must be up to 100 model ids' });
}
}
if (key.startsWith('prompt.') || promptCatalog.find(key)) {
if (!promptCatalog.find(key)) return res.status(400).json({ error: 'Unknown prompt key' });
return changePrompt(req, res, 'save', key);
}
await db.setSetting(key, String(value));
logger.audit(req.user.id, 'admin_config_update', 'Updated config: ' + key, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── Web search ──────────────────────────────────────────────
// Its own routes rather than the generic config setter, because the key must be
// masked on read and preserved when the field is left blank — the same handling
// the OIDC client secret gets.
var WEBSEARCH_KEYS = ['websearch.enabled', 'websearch.provider', 'websearch.api_key', 'websearch.base_url',
'pubmed.enabled', 'pubmed.api_key', 'pubmed.contact_email'];
router.get('/websearch', adminMiddleware, async function (req, res) {
try {
var out = {};
for (var i = 0; i < WEBSEARCH_KEYS.length; i++) {
out[WEBSEARCH_KEYS[i]] = await db.getSetting(WEBSEARCH_KEYS[i], '') || '';
}
// Never send the key back. Enough tail to recognise which one is set.
['websearch.api_key', 'pubmed.api_key'].forEach(function (k) {
if (out[k]) out[k] = '••••••••' + out[k].slice(-4);
});
res.json({ success: true, config: out });
} catch (err) {
logger.error('GET /admin/websearch', err.message);
res.status(500).json({ error: 'Could not load web search settings' });
}
});
router.put('/websearch', adminMiddleware, async function (req, res) {
try {
var providers = require('../utils/webSearch').PROVIDERS;
var provider = String(req.body.provider || 'tavily');
if (providers.indexOf(provider) === -1) return res.status(400).json({ error: 'Unknown provider' });
await db.setSetting('websearch.enabled', String(req.body.enabled) === 'true' ? 'true' : 'false');
await db.setSetting('websearch.provider', provider);
await db.setSetting('websearch.base_url', String(req.body.baseUrl || '').trim().slice(0, 500));
// A blank field means "leave it alone", so editing the provider does not
// silently wipe the key that was already working.
await db.setSetting('pubmed.enabled', String(req.body.pubmedEnabled) === 'true' ? 'true' : 'false');
await db.setSetting('pubmed.contact_email', String(req.body.pubmedEmail || '').trim().slice(0, 200));
// A blank field means "leave it alone", so editing anything else does not
// silently wipe a key that was already working. The mask can never be saved
// back as a key.
var key = String(req.body.apiKey || '').trim();
if (key && key.indexOf('•') === -1) await db.setSetting('websearch.api_key', key.slice(0, 400));
var pmKey = String(req.body.pubmedApiKey || '').trim();
if (pmKey && pmKey.indexOf('•') === -1) await db.setSetting('pubmed.api_key', pmKey.slice(0, 400));
res.json({ success: true });
} catch (err) {
logger.error('PUT /admin/websearch', err.message);
res.status(500).json({ error: 'Could not save web search settings' });
}
});
router.post('/websearch/test', adminMiddleware, async function (req, res) {
try {
var query = String(req.body.query || 'paediatric bronchiolitis guideline');
// Both sources, so one press says which of them actually works.
var web = await require('../utils/webSearch').search(query);
var pubmed = await require('../utils/pubmedSearch').search(query);
res.json({
success: !web.reason || !pubmed.reason,
web: { provider: web.provider || null, count: web.results.length, reason: web.reason || null,
sample: web.results.slice(0, 2).map(function (r) { return { title: r.title, url: r.url }; }) },
pubmed: { count: pubmed.results.length, reason: pubmed.reason || null,
sample: pubmed.results.slice(0, 2).map(function (r) { return { title: r.title, pmid: r.pmid }; }) }
});
} catch (err) {
res.status(502).json({ success: false, reason: err.message });
}
});
module.exports = router;