2 * @file routes/settings.js
3 * @module routes/settings
5 * Settings management API for global and tenant-specific configuration in the RMM-PSA platform.
6 * Provides endpoints for retrieving and updating global defaults, tenant overrides, and WordPress integration credentials.
8 * - Global settings are stored in the `app_settings` table as a JSONB config object.
9 * - Tenant-specific overrides are stored in the `tenant_settings` table as key-value pairs.
10 * - WordPress credentials are managed as a sub-object in global config (root tenant only).
13 * - Retrieve merged global + tenant settings for UI and agent configuration
14 * - Update global defaults (admin/root context) or tenant overrides (tenant context)
15 * - Fine-grained update and retrieval of individual tenant settings
16 * - Secure WordPress credential management (root tenant only)
17 * - Multi-tenant isolation and admin-only update enforcement
20 * - All endpoints require authentication
21 * - Most endpoints require admin privileges for updates
22 * - WordPress credential endpoints restricted to root tenant
23 * - Tenant context enforced for all tenant-specific operations
26 * - app_settings (global config)
27 * - tenant_settings (tenant overrides)
28 * - tenants (for company name sync)
30 * @requires ../middleware/auth
31 * @requires ../middleware/adminOnly
32 * @requires ../middleware/tenant
33 * @requires ../services/db
34 * @author IBG MSP Development Team
39const express = require('express');
40const router = express.Router();
41const authenticateToken = require('../middleware/auth');
42const requireAdmin = require('../middleware/adminOnly');
43const { getTenantFilter, setTenantContext } = require('../middleware/tenant');
44const pool = require('../services/db');
46// Apply authentication and tenant context to all routes
47router.use(authenticateToken, setTenantContext);
49// Ensure app_settings table exists (global settings)
53async function ensureTable() {
55 CREATE TABLE IF NOT EXISTS app_settings (
56 id SERIAL PRIMARY KEY,
58 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
63// GET settings (global defaults merged with tenant overrides)
65 * @api {get} /settings/ Get merged global + tenant settings
66 * @apiName GetSettings
69 * Returns the effective configuration for the current tenant, merging global defaults from `app_settings` with any tenant-specific overrides from `tenant_settings`.
70 * Used for UI configuration, agent policy, and feature toggles.
72 * - If no tenant context, returns global config only.
73 * - If tenant context, merges known overrides (company name, timezone, theme, etc.).
74 * - Default values are provided for missing config fields.
75 * @apiHeader {string} Authorization Bearer JWT token.
76 * @apiSuccess {object} config Merged configuration object.
77 * @apiSuccessExample {json} Success-Response (tenant):
80 * "general": { "companyName": "Acme MSP", "timezone": "America/New_York", "dateFormat": "YYYY-MM-DD" },
81 * "notifications": { "emailEnabled": true, "slackEnabled": false, "alertThresholds": { "cpu": 90, "memory": 90, "disk": 90 } },
82 * "display": { "itemsPerPage": 25, "theme": "dark" }
84 * @apiError (500) ServerError Database or merge error.
85 * @apiExample {curl} Example usage:
86 * curl -H "Authorization: Bearer <token>" https://api.example.com/settings/
89router.get('/', async (req, res) => {
92 // 1) Load global defaults from app_settings
93 const result = await pool.query('SELECT config FROM app_settings ORDER BY id DESC LIMIT 1');
95 if (result.rows.length === 0) {
97 general: { companyName: 'Demo MSP', timezone: 'UTC', dateFormat: 'YYYY-MM-DD' },
98 notifications: { emailEnabled: true, slackEnabled: false, alertThresholds: { cpu: 90, memory: 90, disk: 90 } },
99 display: { itemsPerPage: 10, theme: 'light' }
102 await pool.query('INSERT INTO app_settings (config) VALUES ($1)', [baseConfig]);
104 baseConfig = result.rows[0].config || {};
105 baseConfig.general = baseConfig.general || { companyName: 'Demo MSP', timezone: 'UTC', dateFormat: 'YYYY-MM-DD' };
106 baseConfig.display = baseConfig.display || { itemsPerPage: 10, theme: 'light' };
107 baseConfig.notifications = baseConfig.notifications || { emailEnabled: true, slackEnabled: false, alertThresholds: { cpu: 90, memory: 90, disk: 90 } };
110 // 2) If we have a tenant context, load tenant-specific overrides from tenant_settings
111 const tenantId = req.tenant?.id || req.user?.tenantId || null;
113 // No tenant context -> return global config
114 return res.json(baseConfig);
117 // Ensure tenant_settings table exists
119 CREATE TABLE IF NOT EXISTS tenant_settings (
120 tenant_id INTEGER NOT NULL,
121 setting_key TEXT NOT NULL,
124 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
125 PRIMARY KEY (tenant_id, setting_key)
129 const overridesRes = await pool.query(
130 'SELECT setting_key, setting_value FROM tenant_settings WHERE tenant_id = $1',
134 const overrides = Object.fromEntries(overridesRes.rows.map(r => [r.setting_key, r.setting_value]));
136 // 3) Merge known overrides into the base config
137 const merged = JSON.parse(JSON.stringify(baseConfig));
138 if (overrides.company_name !== undefined) merged.general.companyName = overrides.company_name;
139 if (overrides.general_timezone !== undefined) merged.general.timezone = overrides.general_timezone;
140 if (overrides.general_date_format !== undefined) merged.general.dateFormat = overrides.general_date_format;
142 if (overrides.display_theme !== undefined) {
143 merged.display.theme = overrides.display_theme;
145 if (overrides.display_items_per_page !== undefined) {
146 const n = parseInt(overrides.display_items_per_page, 10);
147 if (!Number.isNaN(n)) merged.display.itemsPerPage = n;
150 return res.json(merged);
152 console.error('Error fetching settings:', err);
153 res.status(500).send('Server error');
157// ========================================
158// WordPress Settings Routes (Root Tenant Only)
159// MUST be defined BEFORE /:key route to avoid conflict
160// ========================================
162// GET /settings/wordpress - Get WordPress global credentials (Root Tenant Only)
164 * @api {get} /settings/wordpress Get WordPress global credentials (root tenant only)
165 * @apiName GetWordPressSettings
168 * Returns the global WordPress integration credentials from the global config. Only accessible by the root tenant (platform admin).
169 * Used for automated WordPress provisioning and backup workflows.
170 * @apiHeader {string} Authorization Bearer JWT token.
171 * @apiSuccess {string} database_password MySQL password.
172 * @apiSuccess {string} spaces_access_key DigitalOcean Spaces access key.
173 * @apiSuccess {string} spaces_secret_key DigitalOcean Spaces secret key.
174 * @apiError (403) Forbidden Only root tenant may access.
175 * @apiError (500) ServerError Database or config error.
176 * @apiExample {curl} Example usage:
177 * curl -H "Authorization: Bearer <token>" https://api.example.com/settings/wordpress
180router.get('/wordpress', async (req, res) => {
182 // Only root tenant (tenant_id = '00000000-0000-0000-0000-000000000001') can access
183 const ROOT_TENANT_ID = '00000000-0000-0000-0000-000000000001';
184 if (!req.tenant || req.tenant.id !== ROOT_TENANT_ID) {
185 return res.status(403).json({ error: 'Access denied. Root tenant only.' });
189 const result = await pool.query('SELECT config FROM app_settings ORDER BY id DESC LIMIT 1');
191 if (result.rows.length === 0 || !result.rows[0].config || !result.rows[0].config.wordpress) {
192 // Return empty credentials if not set
194 database_password: '',
195 spaces_access_key: '',
196 spaces_secret_key: ''
200 const wordpressConfig = result.rows[0].config.wordpress || {};
202 database_password: wordpressConfig.database_password || '',
203 spaces_access_key: wordpressConfig.spaces_access_key || '',
204 spaces_secret_key: wordpressConfig.spaces_secret_key || ''
207 console.error('Error fetching WordPress settings:', err);
208 res.status(500).json({ error: 'Server error' });
212// POST /settings/wordpress - Update WordPress global credentials (Root Tenant Only)
214 * @api {post} /settings/wordpress Update WordPress global credentials (root tenant only)
215 * @apiName UpdateWordPressSettings
218 * Updates the global WordPress integration credentials in the global config. Only accessible by the root tenant (platform admin).
219 * All fields are required. Used for automated WordPress provisioning and backup workflows.
220 * @apiHeader {string} Authorization Bearer JWT token.
221 * @apiParam {string} database_host MySQL host.
222 * @apiParam {string} database_port MySQL port.
223 * @apiParam {string} database_user MySQL user.
224 * @apiParam {string} database_password MySQL password.
225 * @apiParam {string} database_name MySQL database name.
226 * @apiParam {string} database_sslmode MySQL SSL mode.
227 * @apiParam {string} mysql_cluster_id DigitalOcean MySQL cluster ID.
228 * @apiParam {string} spaces_access_key DigitalOcean Spaces access key.
229 * @apiParam {string} spaces_secret_key DigitalOcean Spaces secret key.
230 * @apiSuccess {boolean} success Always true if update succeeded.
231 * @apiSuccess {Object} wordpress Updated WordPress config.
232 * @apiError (400) BadRequest Missing required fields.
233 * @apiError (403) Forbidden Only root tenant may update.
234 * @apiError (500) ServerError Database or config error.
235 * @apiExample {curl} Example usage:
236 * curl -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
237 * -d '{"database_host":"db.example.com","database_port":"3306","database_user":"admin","database_password":"secret","database_name":"wp_db","database_sslmode":"REQUIRED","mysql_cluster_id":"do-cluster","spaces_access_key":"AKIA...","spaces_secret_key":"..."}' \
238 * https://api.example.com/settings/wordpress
241router.post('/wordpress', async (req, res) => {
243 // Only root tenant (tenant_id = '00000000-0000-0000-0000-000000000001') can update
244 const ROOT_TENANT_ID = '00000000-0000-0000-0000-000000000001';
245 if (!req.tenant || req.tenant.id !== ROOT_TENANT_ID) {
246 return res.status(403).json({ error: 'Access denied. Root tenant only.' });
261 // Validate required fields
262 if (!database_host || !database_port || !database_user || !database_password ||
263 !database_name || !mysql_cluster_id || !spaces_access_key || !spaces_secret_key) {
264 return res.status(400).json({
265 error: 'All fields are required: database_host, database_port, database_user, database_password, database_name, database_sslmode, mysql_cluster_id, spaces_access_key, spaces_secret_key'
271 // Get current config or create default
272 const result = await pool.query('SELECT config FROM app_settings ORDER BY id DESC LIMIT 1');
275 if (result.rows.length === 0) {
276 // Create default config with WordPress settings
278 general: { companyName: 'Demo MSP', timezone: 'UTC', dateFormat: 'YYYY-MM-DD' },
279 notifications: { emailEnabled: true, slackEnabled: false, alertThresholds: { cpu: 90, memory: 90, disk: 90 } },
280 display: { itemsPerPage: 10, theme: 'light' },
287 database_sslmode: database_sslmode || 'REQUIRED',
293 await pool.query('INSERT INTO app_settings (config) VALUES ($1)', [config]);
295 // Update existing config with WordPress settings
296 config = result.rows[0].config || {};
303 database_sslmode: database_sslmode || 'REQUIRED',
308 await pool.query('UPDATE app_settings SET config = $1, updated_at = CURRENT_TIMESTAMP WHERE id = (SELECT id FROM app_settings ORDER BY id DESC LIMIT 1)', [config]);
313 message: 'WordPress settings updated successfully',
314 wordpress: config.wordpress
317 console.error('Error updating WordPress settings:', err);
318 res.status(500).json({ error: 'Server error' });
322// GET /settings/:key - for individual tenant settings
324 * @api {get} /settings/:key Get individual tenant setting by key
325 * @apiName GetTenantSetting
328 * Retrieves a single tenant-specific setting by key. Returns default values for known keys if not set.
329 * Used for fine-grained configuration and UI customization.
330 * @apiHeader {string} Authorization Bearer JWT token.
331 * @apiParam {string} key Setting key (URL param).
332 * @apiSuccess {string} setting_key The key requested.
333 * @apiSuccess {string} setting_value The value for this tenant.
334 * @apiSuccess {string} tenant_id Tenant ID.
335 * @apiError (400) BadRequest Tenant context required.
336 * @apiError (404) NotFound Setting not found.
337 * @apiError (500) ServerError Database error.
338 * @apiExample {curl} Example usage:
339 * curl -H "Authorization: Bearer <token>" https://api.example.com/settings/company_name
342router.get('/:key', async (req, res) => {
343 const { key } = req.params;
345 const tenantId = req.tenant?.id || req.user?.tenantId;
347 return res.status(400).json({ error: 'Tenant context required' });
350 const result = await pool.query(
351 'SELECT * FROM tenant_settings WHERE tenant_id = $1 AND setting_key = $2',
355 if (result.rows.length === 0) {
356 // Return defaults for known settings
358 'default_tax_rate': '10.00',
359 'company_name': 'My Company',
361 'company_address': '',
366 if (defaults[key] !== undefined) {
367 return res.json({ setting_key: key, setting_value: defaults[key], tenant_id: tenantId });
370 return res.status(404).json({ error: 'Setting not found' });
373 res.json(result.rows[0]);
375 console.error('Error fetching setting:', err);
376 res.status(500).send('Server error');
380// PUT /settings/:key - update individual tenant setting (Admin only)
382 * @api {put} /settings/:key Update individual tenant setting (admin only)
383 * @apiName UpdateTenantSetting
386 * Updates a single tenant-specific setting by key. Only accessible by tenant admins.
387 * Used for fine-grained configuration and UI customization.
388 * @apiHeader {string} Authorization Bearer JWT token.
389 * @apiParam {string} key Setting key (URL param).
390 * @apiParam {string} setting_value New value for the setting.
391 * @apiParam {string} [description] Optional description.
392 * @apiSuccess {string} setting_key The key updated.
393 * @apiSuccess {string} setting_value The new value.
394 * @apiSuccess {string} tenant_id Tenant ID.
395 * @apiError (400) BadRequest Tenant context required.
396 * @apiError (500) ServerError Database error.
397 * @apiExample {curl} Example usage:
398 * curl -X PUT -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
399 * -d '{"setting_value":"Acme MSP"}' https://api.example.com/settings/company_name
402router.put('/:key', requireAdmin, async (req, res) => {
403 const { key } = req.params;
404 const { setting_value, description } = req.body;
407 const tenantId = req.tenant?.id || req.user?.tenantId;
409 return res.status(400).json({ error: 'Tenant context required' });
412 const result = await pool.query(
413 `INSERT INTO tenant_settings (tenant_id, setting_key, setting_value, description, updated_at)
414 VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
415 ON CONFLICT (tenant_id, setting_key)
416 DO UPDATE SET setting_value = $3, description = $4, updated_at = CURRENT_TIMESTAMP
418 [tenantId, key, setting_value, description]
420 res.json(result.rows[0]);
422 console.error('Error updating setting:', err);
423 res.status(500).send('Failed to update setting');
427// PUT update settings (Admin only)
428// If called in a tenant context (non-admin subdomain), persist tenant overrides in tenant_settings.
429// If called in the root/admin context, replace global defaults in app_settings.
430router.put('/', authenticateToken, requireAdmin, async (req, res) => {
433 const tenantId = req.tenant?.id || req.user?.tenantId || null;
434 const isAdminSubdomain = req.tenant?.subdomain === 'admin' || req.tenant?.is_msp === true;
435 const payload = req.body || {};
437 // If NOT in a specific tenant context (or in admin/root context), write global defaults
438 if (!tenantId || isAdminSubdomain) {
440 const result = await pool.query('INSERT INTO app_settings (config) VALUES ($1) RETURNING config', [payload]);
441 return res.json(result.rows[0].config);
444 // Otherwise, we are updating settings for a particular tenant -> map to tenant_settings keys
446 CREATE TABLE IF NOT EXISTS tenant_settings (
447 tenant_id INTEGER NOT NULL,
448 setting_key TEXT NOT NULL,
451 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
452 PRIMARY KEY (tenant_id, setting_key)
456 // Build batch of upserts for known fields
458 const pushUpsert = (key, val, desc) => {
459 if (val === undefined || val === null) return;
460 upserts.push(pool.query(
461 `INSERT INTO tenant_settings (tenant_id, setting_key, setting_value, description, updated_at)
462 VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
463 ON CONFLICT (tenant_id, setting_key)
464 DO UPDATE SET setting_value = EXCLUDED.setting_value, description = EXCLUDED.description, updated_at = CURRENT_TIMESTAMP`,
465 [tenantId, key, String(val), desc || null]
470 if (payload.general) {
471 pushUpsert('company_name', payload.general.companyName, 'General: Company name');
472 pushUpsert('general_timezone', payload.general.timezone, 'General: Timezone');
473 pushUpsert('general_date_format', payload.general.dateFormat, 'General: Date format');
474 // Also update the tenant name in tenants table if companyName is present
475 if (payload.general.companyName) {
478 'UPDATE tenants SET name = $1 WHERE tenant_id = $2',
479 [payload.general.companyName, tenantId]
482 console.error('Error updating tenant name:', err);
488 if (payload.display) {
489 pushUpsert('display_theme', payload.display.theme, 'Display: Theme');
490 pushUpsert('display_items_per_page', payload.display.itemsPerPage, 'Display: Items per page');
493 // Notifications (optional future mapping)
494 // if (payload.notifications) { ... }
496 await Promise.all(upserts);
498 // Recompute merged config for this tenant (same logic as GET)
500 const result = await pool.query('SELECT config FROM app_settings ORDER BY id DESC LIMIT 1');
501 let baseConfig = (result.rows[0] && result.rows[0].config) ? result.rows[0].config : {
502 general: { companyName: 'Demo MSP', timezone: 'UTC', dateFormat: 'YYYY-MM-DD' },
503 notifications: { emailEnabled: true, slackEnabled: false, alertThresholds: { cpu: 90, memory: 90, disk: 90 } },
504 display: { itemsPerPage: 10, theme: 'light' }
506 baseConfig.general = baseConfig.general || { companyName: 'Demo MSP', timezone: 'UTC', dateFormat: 'YYYY-MM-DD' };
507 baseConfig.display = baseConfig.display || { itemsPerPage: 10, theme: 'light' };
508 baseConfig.notifications = baseConfig.notifications || { emailEnabled: true, slackEnabled: false, alertThresholds: { cpu: 90, memory: 90, disk: 90 } };
510 const overridesRes = await pool.query(
511 'SELECT setting_key, setting_value FROM tenant_settings WHERE tenant_id = $1',
514 const overrides = Object.fromEntries(overridesRes.rows.map(r => [r.setting_key, r.setting_value]));
515 const merged = JSON.parse(JSON.stringify(baseConfig));
516 if (overrides.company_name !== undefined) merged.general.companyName = overrides.company_name;
517 if (overrides.general_timezone !== undefined) merged.general.timezone = overrides.general_timezone;
518 if (overrides.general_date_format !== undefined) merged.general.dateFormat = overrides.general_date_format;
519 if (overrides.display_theme !== undefined) merged.display.theme = overrides.display_theme;
520 if (overrides.display_items_per_page !== undefined) {
521 const n = parseInt(overrides.display_items_per_page, 10);
522 if (!Number.isNaN(n)) merged.display.itemsPerPage = n;
524 return res.json(merged);
526 console.error('Error updating settings:', err);
527 res.status(500).send('Server error');
532 * @api {put} /settings/ Update settings (admin only, global or tenant context)
533 * @apiName UpdateSettings
536 * Updates settings for the current context:
537 * - In admin/root context, replaces global defaults in `app_settings`.
538 * - In tenant context, persists tenant overrides in `tenant_settings`.
539 * - Only accessible by admins.
540 * - Also syncs company name to tenants table if present.
541 * @apiHeader {string} Authorization Bearer JWT token.
542 * @apiParam {object} config New config object (see GET /settings/ for structure).
543 * @apiSuccess {object} config Updated merged config for this tenant or global.
544 * @apiError (500) ServerError Database or merge error.
545 * @apiExample {curl} Example usage (tenant):
546 * curl -X PUT -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
547 * -d '{"general":{"companyName":"Acme MSP","timezone":"America/New_York"},"display":{"theme":"dark"}}' \
548 * https://api.example.com/settings/
551module.exports = router;