EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
settings.js
Go to the documentation of this file.
1/**
2 * @file routes/settings.js
3 * @module routes/settings
4 * @description
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.
7 *
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).
11 *
12 * **Core Features:**
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
18 *
19 * **Security:**
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
24 *
25 * **Related Models:**
26 * - app_settings (global config)
27 * - tenant_settings (tenant overrides)
28 * - tenants (for company name sync)
29 * @requires express
30 * @requires ../middleware/auth
31 * @requires ../middleware/adminOnly
32 * @requires ../middleware/tenant
33 * @requires ../services/db
34 * @author IBG MSP Development Team
35 * @date 2026-03-12
36 * @since 1.0.0
37 */
38
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');
45
46// Apply authentication and tenant context to all routes
47router.use(authenticateToken, setTenantContext);
48
49// Ensure app_settings table exists (global settings)
50/**
51 *
52 */
53async function ensureTable() {
54 await pool.query(`
55 CREATE TABLE IF NOT EXISTS app_settings (
56 id SERIAL PRIMARY KEY,
57 config JSONB,
58 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
59 )
60 `);
61}
62
63// GET settings (global defaults merged with tenant overrides)
64/**
65 * @api {get} /settings/ Get merged global + tenant settings
66 * @apiName GetSettings
67 * @apiGroup Settings
68 * @apiDescription
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.
71 *
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):
78 * HTTP/1.1 200 OK
79 * {
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" }
83 * }
84 * @apiError (500) ServerError Database or merge error.
85 * @apiExample {curl} Example usage:
86 * curl -H "Authorization: Bearer <token>" https://api.example.com/settings/
87 * @since 1.0.0
88 */
89router.get('/', async (req, res) => {
90 try {
91 await ensureTable();
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');
94 let baseConfig;
95 if (result.rows.length === 0) {
96 baseConfig = {
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' }
100 };
101 // insert default
102 await pool.query('INSERT INTO app_settings (config) VALUES ($1)', [baseConfig]);
103 } else {
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 } };
108 }
109
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;
112 if (!tenantId) {
113 // No tenant context -> return global config
114 return res.json(baseConfig);
115 }
116
117 // Ensure tenant_settings table exists
118 await pool.query(`
119 CREATE TABLE IF NOT EXISTS tenant_settings (
120 tenant_id INTEGER NOT NULL,
121 setting_key TEXT NOT NULL,
122 setting_value TEXT,
123 description TEXT,
124 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
125 PRIMARY KEY (tenant_id, setting_key)
126 )
127 `);
128
129 const overridesRes = await pool.query(
130 'SELECT setting_key, setting_value FROM tenant_settings WHERE tenant_id = $1',
131 [tenantId]
132 );
133
134 const overrides = Object.fromEntries(overridesRes.rows.map(r => [r.setting_key, r.setting_value]));
135
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;
141
142 if (overrides.display_theme !== undefined) {
143 merged.display.theme = overrides.display_theme;
144 }
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;
148 }
149
150 return res.json(merged);
151 } catch (err) {
152 console.error('Error fetching settings:', err);
153 res.status(500).send('Server error');
154 }
155});
156
157// ========================================
158// WordPress Settings Routes (Root Tenant Only)
159// MUST be defined BEFORE /:key route to avoid conflict
160// ========================================
161
162// GET /settings/wordpress - Get WordPress global credentials (Root Tenant Only)
163/**
164 * @api {get} /settings/wordpress Get WordPress global credentials (root tenant only)
165 * @apiName GetWordPressSettings
166 * @apiGroup Settings
167 * @apiDescription
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
178 * @since 1.0.0
179 */
180router.get('/wordpress', async (req, res) => {
181 try {
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.' });
186 }
187
188 await ensureTable();
189 const result = await pool.query('SELECT config FROM app_settings ORDER BY id DESC LIMIT 1');
190
191 if (result.rows.length === 0 || !result.rows[0].config || !result.rows[0].config.wordpress) {
192 // Return empty credentials if not set
193 return res.json({
194 database_password: '',
195 spaces_access_key: '',
196 spaces_secret_key: ''
197 });
198 }
199
200 const wordpressConfig = result.rows[0].config.wordpress || {};
201 return res.json({
202 database_password: wordpressConfig.database_password || '',
203 spaces_access_key: wordpressConfig.spaces_access_key || '',
204 spaces_secret_key: wordpressConfig.spaces_secret_key || ''
205 });
206 } catch (err) {
207 console.error('Error fetching WordPress settings:', err);
208 res.status(500).json({ error: 'Server error' });
209 }
210});
211
212// POST /settings/wordpress - Update WordPress global credentials (Root Tenant Only)
213/**
214 * @api {post} /settings/wordpress Update WordPress global credentials (root tenant only)
215 * @apiName UpdateWordPressSettings
216 * @apiGroup Settings
217 * @apiDescription
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
239 * @since 1.0.0
240 */
241router.post('/wordpress', async (req, res) => {
242 try {
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.' });
247 }
248
249 const {
250 database_host,
251 database_port,
252 database_user,
253 database_password,
254 database_name,
255 database_sslmode,
256 mysql_cluster_id,
257 spaces_access_key,
258 spaces_secret_key
259 } = req.body;
260
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'
266 });
267 }
268
269 await ensureTable();
270
271 // Get current config or create default
272 const result = await pool.query('SELECT config FROM app_settings ORDER BY id DESC LIMIT 1');
273 let config;
274
275 if (result.rows.length === 0) {
276 // Create default config with WordPress settings
277 config = {
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' },
281 wordpress: {
282 database_host,
283 database_port,
284 database_user,
285 database_password,
286 database_name,
287 database_sslmode: database_sslmode || 'REQUIRED',
288 mysql_cluster_id,
289 spaces_access_key,
290 spaces_secret_key
291 }
292 };
293 await pool.query('INSERT INTO app_settings (config) VALUES ($1)', [config]);
294 } else {
295 // Update existing config with WordPress settings
296 config = result.rows[0].config || {};
297 config.wordpress = {
298 database_host,
299 database_port,
300 database_user,
301 database_password,
302 database_name,
303 database_sslmode: database_sslmode || 'REQUIRED',
304 mysql_cluster_id,
305 spaces_access_key,
306 spaces_secret_key
307 };
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]);
309 }
310
311 res.json({
312 success: true,
313 message: 'WordPress settings updated successfully',
314 wordpress: config.wordpress
315 });
316 } catch (err) {
317 console.error('Error updating WordPress settings:', err);
318 res.status(500).json({ error: 'Server error' });
319 }
320});
321
322// GET /settings/:key - for individual tenant settings
323/**
324 * @api {get} /settings/:key Get individual tenant setting by key
325 * @apiName GetTenantSetting
326 * @apiGroup Settings
327 * @apiDescription
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
340 * @since 1.0.0
341 */
342router.get('/:key', async (req, res) => {
343 const { key } = req.params;
344 try {
345 const tenantId = req.tenant?.id || req.user?.tenantId;
346 if (!tenantId) {
347 return res.status(400).json({ error: 'Tenant context required' });
348 }
349
350 const result = await pool.query(
351 'SELECT * FROM tenant_settings WHERE tenant_id = $1 AND setting_key = $2',
352 [tenantId, key]
353 );
354
355 if (result.rows.length === 0) {
356 // Return defaults for known settings
357 const defaults = {
358 'default_tax_rate': '10.00',
359 'company_name': 'My Company',
360 'company_abn': '',
361 'company_address': '',
362 'company_phone': '',
363 'company_email': ''
364 };
365
366 if (defaults[key] !== undefined) {
367 return res.json({ setting_key: key, setting_value: defaults[key], tenant_id: tenantId });
368 }
369
370 return res.status(404).json({ error: 'Setting not found' });
371 }
372
373 res.json(result.rows[0]);
374 } catch (err) {
375 console.error('Error fetching setting:', err);
376 res.status(500).send('Server error');
377 }
378});
379
380// PUT /settings/:key - update individual tenant setting (Admin only)
381/**
382 * @api {put} /settings/:key Update individual tenant setting (admin only)
383 * @apiName UpdateTenantSetting
384 * @apiGroup Settings
385 * @apiDescription
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
400 * @since 1.0.0
401 */
402router.put('/:key', requireAdmin, async (req, res) => {
403 const { key } = req.params;
404 const { setting_value, description } = req.body;
405
406 try {
407 const tenantId = req.tenant?.id || req.user?.tenantId;
408 if (!tenantId) {
409 return res.status(400).json({ error: 'Tenant context required' });
410 }
411
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
417 RETURNING *`,
418 [tenantId, key, setting_value, description]
419 );
420 res.json(result.rows[0]);
421 } catch (err) {
422 console.error('Error updating setting:', err);
423 res.status(500).send('Failed to update setting');
424 }
425});
426
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) => {
431 try {
432
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 || {};
436
437 // If NOT in a specific tenant context (or in admin/root context), write global defaults
438 if (!tenantId || isAdminSubdomain) {
439 await ensureTable();
440 const result = await pool.query('INSERT INTO app_settings (config) VALUES ($1) RETURNING config', [payload]);
441 return res.json(result.rows[0].config);
442 }
443
444 // Otherwise, we are updating settings for a particular tenant -> map to tenant_settings keys
445 await pool.query(`
446 CREATE TABLE IF NOT EXISTS tenant_settings (
447 tenant_id INTEGER NOT NULL,
448 setting_key TEXT NOT NULL,
449 setting_value TEXT,
450 description TEXT,
451 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
452 PRIMARY KEY (tenant_id, setting_key)
453 )
454 `);
455
456 // Build batch of upserts for known fields
457 const upserts = [];
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]
466 ));
467 };
468
469 // General
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) {
476 try {
477 await pool.query(
478 'UPDATE tenants SET name = $1 WHERE tenant_id = $2',
479 [payload.general.companyName, tenantId]
480 );
481 } catch (err) {
482 console.error('Error updating tenant name:', err);
483 }
484 }
485 }
486
487 // Display
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');
491 }
492
493 // Notifications (optional future mapping)
494 // if (payload.notifications) { ... }
495
496 await Promise.all(upserts);
497
498 // Recompute merged config for this tenant (same logic as GET)
499 await ensureTable();
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' }
505 };
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 } };
509
510 const overridesRes = await pool.query(
511 'SELECT setting_key, setting_value FROM tenant_settings WHERE tenant_id = $1',
512 [tenantId]
513 );
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;
523 }
524 return res.json(merged);
525 } catch (err) {
526 console.error('Error updating settings:', err);
527 res.status(500).send('Server error');
528 }
529});
530
531/**
532 * @api {put} /settings/ Update settings (admin only, global or tenant context)
533 * @apiName UpdateSettings
534 * @apiGroup Settings
535 * @apiDescription
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/
549 * @since 1.0.0
550 */
551module.exports = router;