- Root cause: PUT /config/:key(*) wildcard was registered before /config/models/toggle and /config/models/default, intercepting them and returning "value is required" (body had modelId not value) - Fix: move all model-specific PUT routes before the wildcard - LiteLLM: return empty built-in list with discovery hint (hardcoded models don't match user's proxy — must use Search API) - After adding a discovered model: auto-select it in the default dropdown - GET /config/models now returns defaultModel so dropdown pre-selects it
408 lines
20 KiB
JavaScript
408 lines
20 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 logger = require('../utils/logger');
|
|
|
|
router.use(authMiddleware);
|
|
|
|
// ── 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: e.message }); }
|
|
});
|
|
|
|
router.use(adminMiddleware);
|
|
|
|
// ── GET all config entries ─────────────────────────────────────────────────
|
|
router.get('/config', async function(req, res) {
|
|
try {
|
|
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 });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── 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 btnStyle = require('./auth').__btnStyle;
|
|
|
|
var html = emailWrapper(
|
|
'<p style="color:#4b5563;margin:0 0 20px;line-height:1.6;">' + bodyText.replace(/\n/g, '<br>') + '</p>' +
|
|
(btnStyle ? '<a href="#" style="' + btnStyle() + '">Test Button</a>' : '')
|
|
);
|
|
|
|
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: e.message }); }
|
|
});
|
|
|
|
// ── GET prompt list (for editor UI) ───────────────────────────────────────
|
|
router.get('/config/prompts', async function(req, res) {
|
|
try {
|
|
var prompts = PROMPTS.getAllPrompts();
|
|
// Load any DB overrides
|
|
var dbRows = await db.all("SELECT key, value FROM app_settings WHERE key LIKE 'prompt.%'", []);
|
|
var dbMap = {};
|
|
dbRows.forEach(function(r) { dbMap[r.key] = r.value; });
|
|
prompts.forEach(function(p) {
|
|
var dbKey = 'prompt.' + p.key;
|
|
if (dbMap[dbKey]) p.value = dbMap[dbKey];
|
|
p.dbKey = dbKey;
|
|
});
|
|
res.json({ success: true, prompts: prompts });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── POST reset a prompt to hardcoded default ──────────────────────────────
|
|
router.post('/config/prompts/:key/reset', async function(req, res) {
|
|
try {
|
|
var key = req.params.key;
|
|
if (!Object.prototype.hasOwnProperty.call(PROMPTS, key) || typeof PROMPTS[key] !== 'string') {
|
|
return res.status(404).json({ error: 'Prompt not found' });
|
|
}
|
|
// Delete DB override so hardcoded default is used
|
|
await db.run("DELETE FROM app_settings WHERE key = ?", ['prompt.' + key]);
|
|
// Reload from hardcoded source (re-require)
|
|
delete require.cache[require.resolve('../utils/prompts')];
|
|
var fresh = require('../utils/prompts');
|
|
PROMPTS[key] = fresh[key] || PROMPTS[key];
|
|
|
|
logger.audit(req.user.id, 'admin_config_reset', 'Reset prompt to default: ' + key, req, { category: 'admin' });
|
|
res.json({ success: true, value: PROMPTS[key] });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── 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: e.message }); }
|
|
});
|
|
|
|
// ── 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: e.message }); }
|
|
});
|
|
|
|
// ── 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: e.message }); }
|
|
});
|
|
|
|
// ── 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: e.message }); }
|
|
});
|
|
|
|
// ============================================================
|
|
// 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, VERTEX_MODELS, activeProvider } = require('../utils/models');
|
|
var providerModels;
|
|
switch (activeProvider) {
|
|
case 'bedrock': providerModels = BEDROCK_MODELS; break;
|
|
case 'azure': providerModels = AZURE_MODELS; break;
|
|
case 'vertex': providerModels = VERTEX_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 db.getSetting('models.default') || '';
|
|
var disabled, custom;
|
|
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
|
|
|
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: e.message }); }
|
|
});
|
|
|
|
// ── PUT toggle model enabled/disabled ────────────────────────────────────
|
|
router.put('/config/models/toggle', async function(req, res) {
|
|
try {
|
|
var { modelId, enabled } = req.body;
|
|
if (!modelId) return res.status(400).json({ error: 'modelId required' });
|
|
|
|
var disabledRaw = await db.getSetting('models.disabled') || '[]';
|
|
var disabled;
|
|
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
|
|
|
if (enabled) {
|
|
disabled = disabled.filter(function(id) { return id !== modelId; });
|
|
} else {
|
|
if (!disabled.includes(modelId)) disabled.push(modelId);
|
|
|
|
// Safety: prevent disabling all models
|
|
var { getAvailableModels } = require('../utils/models');
|
|
var allModels = getAvailableModels();
|
|
var customRaw = await db.getSetting('models.custom') || '[]';
|
|
var custom;
|
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
|
var enabledCount = allModels.filter(function(m) { return !disabled.includes(m.id); }).length + custom.length;
|
|
if (enabledCount === 0) {
|
|
return res.status(400).json({ error: 'Cannot disable all models. At least one must remain enabled.' });
|
|
}
|
|
}
|
|
|
|
await db.setSetting('models.disabled', JSON.stringify(disabled));
|
|
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: e.message }); }
|
|
});
|
|
|
|
// ── PUT set default model ─────────────────────────────────────────────────
|
|
router.put('/config/models/default', async function(req, res) {
|
|
try {
|
|
var { modelId } = req.body;
|
|
if (!modelId) return res.status(400).json({ error: 'modelId required' });
|
|
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: e.message }); }
|
|
});
|
|
|
|
// ── POST add custom model (manual entry) ─────────────────────────────────
|
|
router.post('/config/models/custom', async function(req, res) {
|
|
try {
|
|
var { id, name, cost, category } = 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, VERTEX_MODELS } = require('../utils/models');
|
|
var allBuiltIn = [].concat(OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_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 validCategories = ['free', 'fast', 'smart', 'premium'];
|
|
var cat = validCategories.includes(category) ? category : 'smart';
|
|
|
|
var customRaw = await db.getSetting('models.custom') || '[]';
|
|
var custom;
|
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
|
|
|
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), cost: (cost || '?').substring(0, 20), category: cat, tag: 'CUSTOM' });
|
|
|
|
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: e.message }); }
|
|
});
|
|
|
|
// ── DELETE custom model ───────────────────────────────────────────────────
|
|
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;
|
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
|
custom = custom.filter(function(m) { return m.id !== modelId; });
|
|
await db.setSetting('models.custom', JSON.stringify(custom));
|
|
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: e.message }); }
|
|
});
|
|
|
|
// ── GET discover models from provider API ─────────────────────────────────
|
|
router.get('/config/models/discover', async function(req, res) {
|
|
try {
|
|
var { discoverModels } = require('../utils/ai');
|
|
var discovered = await discoverModels();
|
|
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: e.message }); }
|
|
});
|
|
|
|
// ── POST add discovered model to custom list ──────────────────────────────
|
|
router.post('/config/models/add-discovered', async function(req, res) {
|
|
try {
|
|
var { id, name, cost, category } = 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 validCategories = ['free', 'fast', 'smart', 'premium'];
|
|
var cat = validCategories.includes(category) ? category : 'smart';
|
|
|
|
var customRaw = await db.getSetting('models.custom') || '[]';
|
|
var custom;
|
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
|
|
|
custom = custom.filter(function(m) { return m.id !== trimmedId; });
|
|
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'DISCOVERED' });
|
|
|
|
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: 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', 'site.', 'smtp.', 'models.'];
|
|
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
|
|
if (!isAllowed) {
|
|
return res.status(400).json({ error: 'Unknown config key' });
|
|
}
|
|
|
|
await db.setSetting(key, String(value));
|
|
|
|
// Update in-memory prompt immediately if it's a prompt key
|
|
if (key.startsWith('prompt.')) {
|
|
var promptKey = key.replace('prompt.', '');
|
|
PROMPTS.updatePrompt(promptKey, 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: e.message }); }
|
|
});
|
|
|
|
module.exports = router;
|