2 * @file routes/tenants.js
3 * @module routes/tenants
4 * @description Multi-tenant management API for MSP platform. Handles tenant lifecycle, subdomain
5 * provisioning, MeshCentral device group integration, DNS automation, user provisioning, and
6 * comprehensive audit logging. Root MSP administrators manage all tenant operations.
8 * **Multi-Tenant Architecture:**
9 * - Root MSP tenant (is_msp=true) manages multiple customer tenants
10 * - Each tenant has unique subdomain, isolated data, and user accounts
11 * - Tenant-based data filtering enforced across entire platform
12 * - Subdomain-based routing for white-label customer portals
14 * **Tenant Lifecycle:**
15 * 1. Create - Generate subdomain, provision DNS, create MeshCentral group
16 * 2. Active - Full operational access to platform features
17 * 3. Suspended - Temporary access restriction (billing issues, security)
18 * 4. Inactive - Soft delete, data retained
19 * 5. Delete - Permanent removal with cascade options
21 * **Subdomain Provisioning:**
22 * - Automatic Cloudflare DNS record creation (CNAME to base domain)
23 * - Format validation (lowercase, alphanumeric + hyphens)
24 * - Reserved subdomain blocking (admin, api, www, app, etc.)
25 * - Immutable after creation (prevents broken links/users)
26 * - Example: acmecorp.app.everydaytech.au → app.everydaytech.au
28 * **MeshCentral Integration:**
29 * - Automatic device group creation on tenant creation
30 * - meshcentral_group_id stored in tenants table
31 * - Device isolation per tenant for RMM security
32 * - Group naming: "{tenant_name} Devices"
33 * - Fallback: Group created on next agent sync if creation fails
36 * - All tenant operations logged to tenant_audit_log table
37 * - Create, update, delete, user creation, MSP status changes
38 * - User attribution (who performed action)
39 * - Detailed JSON payloads for change tracking
40 * - Impersonation audit trail (separate endpoint)
43 * - Most routes require Root MSP access (requireRoot middleware)
44 * - Regular tenant users can only view their current tenant
45 * - Prevent root/MSP tenant deletion
46 * - Prevent multiple root tenant creation
47 * - Force-delete option requires explicit query parameter
50 * - tenants table (tenant_id, name, subdomain, status, is_msp, meshcentral_group_id)
51 * - tenant_audit_log table (comprehensive action logging)
52 * - tenant_settings table (per-tenant configuration)
53 * - users, customers, tickets, invoices, etc. (all tenant-scoped)
55 * @requires ../services/db
56 * @requires ../services/cloudflare
57 * @requires ../middleware/auth
58 * @requires ../middleware/tenant
59 * @requires ../lib/meshcentral-api
60 * @author IBG MSP Development Team
63 * @see {@link module:routes/users} for user management
64 * @see {@link module:middleware/tenant} for tenant context filtering
67const { createSubdomain } = require('../services/cloudflare');
68const express = require('express');
69const router = express.Router();
70const pool = require('../services/db');
71const authenticateToken = require('../middleware/auth');
72const { requireRoot, withTenantContext, setTenantContext } = require('../middleware/tenant');
74// Apply tenant context to all routes in this router
75router.use(authenticateToken, setTenantContext);
78 * @api {get} /tenants/current Get Current Tenant
79 * @apiName GetCurrentTenant
81 * @apiDescription Retrieves the authenticated user's tenant details. Available to all authenticated
82 * users to view their own tenant information (company name, subdomain, status, etc.).
85 * - Display tenant branding in UI header
86 * - Show current tenant context in multi-tenant dashboards
87 * - Validate tenant status before operations
88 * - Retrieve subdomain for URL construction
91 * Uses req.user.tenantId from JWT token to lookup tenant. No special permissions required -
92 * all authenticated users can view their own tenant.
93 * @apiHeader {string} Authorization Bearer JWT token
94 * @apiSuccess {number} tenant_id Unique tenant ID
95 * @apiSuccess {string} name Tenant/company name
96 * @apiSuccess {string} subdomain Unique subdomain identifier
97 * @apiSuccess {string} status Tenant status (active, suspended, inactive)
98 * @apiSuccess {boolean} is_msp Whether tenant is root MSP (true only for platform admin)
99 * @apiSuccess {string} meshcentral_group_id MeshCentral device group ID
100 * @apiSuccess {boolean} meshcentral_setup_complete Whether MeshCentral integration complete
101 * @apiSuccess {string} created_at Tenant creation timestamp
102 * @apiSuccess {string} updated_at Last update timestamp
103 * @apiError {number} 404 Tenant not found (data integrity issue)
104 * @apiError {number} 500 Failed to fetch tenant
105 * @apiExample {curl} Example Request:
107 * -H "Authorization: Bearer eyJhbGc..." \
108 * "https://api.everydaytech.au/tenants/current"
109 * @apiExample {json} Success Response:
113 * "name": "Acme Corporation",
114 * "subdomain": "acmecorp",
115 * "status": "active",
117 * "meshcentral_group_id": "mesh://server/mesh1234567890abcdef",
118 * "meshcentral_setup_complete": true,
119 * "created_at": "2024-01-15T10:00:00.000Z",
120 * "updated_at": "2024-03-01T14:30:00.000Z"
123 * @see {@link module:routes/tenants.getTenant} for detailed tenant stats
125router.get('/current', async (req, res) => {
127 const result = await pool.query(
128 'SELECT * FROM tenants WHERE tenant_id = $1',
132 if (result.rows.length === 0) {
133 return res.status(404).json({ error: 'Tenant not found' });
136 res.json(result.rows[0]);
138 console.error('Error fetching current tenant:', err);
139 res.status(500).json({ error: 'Failed to fetch tenant' });
144 * @api {get} /tenants List All Tenants
145 * @apiName GetTenants
147 * @apiDescription Retrieves complete list of all tenants with aggregated statistics. Root MSP
148 * administrators only. Includes user counts, customer counts, and ticket counts for each tenant.
149 * Used for MSP dashboard overview and tenant management.
151 * **Statistics Included:**
152 * - user_count: Total users in tenant
153 * - customer_count: Total customers managed by tenant
154 * - ticket_count: Total tickets created in tenant
157 * Tenants sorted by creation date (newest first). Shows recent tenant additions at top.
159 * **Access Control:**
160 * Requires Root MSP authentication (requireRoot middleware). Regular tenant users receive
162 * @apiHeader {string} Authorization Bearer JWT token (root MSP admin required)
163 * @apiSuccess {object[]} tenants Array of tenant objects with statistics
164 * @apiSuccess {number} tenants.tenant_id Unique tenant ID
165 * @apiSuccess {string} tenants.name Tenant/company name
166 * @apiSuccess {string} tenants.subdomain Unique subdomain
167 * @apiSuccess {string} tenants.status Tenant status (active, suspended, inactive)
168 * @apiSuccess {boolean} tenants.is_msp Whether tenant is MSP
169 * @apiSuccess {string} tenants.meshcentral_group_id MeshCentral device group ID
170 * @apiSuccess {string} tenants.created_at Creation timestamp
171 * @apiSuccess {number} tenants.user_count Total users in tenant
172 * @apiSuccess {number} tenants.customer_count Total customers in tenant
173 * @apiSuccess {number} tenants.ticket_count Total tickets in tenant
174 * @apiError {number} 403 Forbidden (not root MSP admin)
175 * @apiError {number} 500 Failed to fetch tenants
176 * @apiExample {curl} Example Request:
178 * -H "Authorization: Bearer eyJhbGc..." \
179 * "https://api.everydaytech.au/tenants"
180 * @apiExample {json} Success Response:
186 * "name": "Acme Corporation",
187 * "subdomain": "acmecorp",
188 * "status": "active",
190 * "meshcentral_group_id": "mesh://server/mesh1234567890abcdef",
191 * "created_at": "2024-01-15T10:00:00.000Z",
192 * "user_count": "23",
193 * "customer_count": "145",
194 * "ticket_count": "892"
198 * "name": "Root MSP",
199 * "subdomain": "root.demo",
200 * "status": "active",
202 * "meshcentral_group_id": null,
203 * "created_at": "2023-11-01T00:00:00.000Z",
205 * "customer_count": "0",
206 * "ticket_count": "0"
211 * @see {@link module:routes/tenants.getTenant} for detailed single tenant info
212 * @see {@link module:routes/tenants.createTenant} for creating tenants
214router.get('/', requireRoot, async (req, res) => {
216 const result = await pool.query(`
219 COUNT(DISTINCT u.user_id) as user_count,
220 COUNT(DISTINCT c.customer_id) as customer_count,
221 COUNT(DISTINCT tk.ticket_id) as ticket_count
223 LEFT JOIN users u ON t.tenant_id = u.tenant_id
224 LEFT JOIN customers c ON t.tenant_id = c.tenant_id
225 LEFT JOIN tickets tk ON t.tenant_id = tk.tenant_id
227 ORDER BY t.created_at DESC
230 res.json({ tenants: result.rows });
232 console.error('Error fetching tenants:', err);
233 res.status(500).json({ error: 'Failed to fetch tenants' });
238 * @api {get} /tenants/:id Get Tenant Details
241 * @apiDescription Retrieves detailed information for a specific tenant with comprehensive statistics.
242 * Root MSP administrators only. Provides all tenant details plus counts of users, customers,
243 * tickets, invoices, contracts, and agents.
245 * **Statistics Provided:**
246 * - user_count: Total users in tenant
247 * - customer_count: Total customers managed
248 * - ticket_count: Total support tickets
249 * - invoice_count: Total invoices issued
250 * - contract_count: Total service contracts
251 * - agent_count: Total RMM agents deployed
254 * - Tenant detail page in MSP admin dashboard
255 * - Tenant health monitoring
256 * - Capacity planning and resource allocation
257 * - Billing verification
258 * @apiHeader {string} Authorization Bearer JWT token (root MSP admin required)
259 * @apiParam {number} id Tenant ID (in URL path)
260 * @apiSuccess {object} tenant Tenant object with all details
261 * @apiSuccess {number} tenant.tenant_id Unique tenant ID
262 * @apiSuccess {string} tenant.name Tenant/company name
263 * @apiSuccess {string} tenant.subdomain Unique subdomain
264 * @apiSuccess {string} tenant.status Tenant status (active, suspended, inactive)
265 * @apiSuccess {boolean} tenant.is_msp Whether tenant is MSP
266 * @apiSuccess {string} tenant.meshcentral_group_id MeshCentral device group ID
267 * @apiSuccess {boolean} tenant.meshcentral_setup_complete MeshCentral integration status
268 * @apiSuccess {string} tenant.created_at Creation timestamp
269 * @apiSuccess {string} tenant.updated_at Last update timestamp
270 * @apiSuccess {Object} stats Aggregated statistics
271 * @apiSuccess {string} stats.user_count Total users
272 * @apiSuccess {string} stats.customer_count Total customers
273 * @apiSuccess {string} stats.ticket_count Total tickets
274 * @apiSuccess {string} stats.invoice_count Total invoices
275 * @apiSuccess {string} stats.contract_count Total contracts
276 * @apiSuccess {String} stats.agent_count Total RMM agents
278 * @apiError {Number} 403 Forbidden (not root MSP admin)
279 * @apiError {Number} 404 Tenant not found
280 * @apiError {Number} 500 Failed to fetch tenant
282 * @apiExample {curl} Example Request:
284 * -H "Authorization: Bearer eyJhbGc..." \
285 * "https://api.everydaytech.au/tenants/5"
287 * @apiExample {json} Success Response:
292 * "name": "Acme Corporation",
293 * "subdomain": "acmecorp",
294 * "status": "active",
296 * "meshcentral_group_id": "mesh://server/mesh1234567890abcdef",
297 * "meshcentral_setup_complete": true,
298 * "created_at": "2024-01-15T10:00:00.000Z",
299 * "updated_at": "2024-03-01T14:30:00.000Z"
302 * "user_count": "23",
303 * "customer_count": "145",
304 * "ticket_count": "892",
305 * "invoice_count": "1247",
306 * "contract_count": "89",
307 * "agent_count": "432"
312 * @see {@link module:routes/tenants.getTenants} for list of all tenants
313 * @see {@link module:routes/tenants.updateTenant} for updating tenant
315router.get('/:id', requireRoot, async (req, res) => {
316 const { id } = req.params;
319 const tenantQuery = await pool.query(
320 'SELECT * FROM tenants WHERE tenant_id = $1',
324 if (tenantQuery.rows.length === 0) {
325 return res.status(404).json({ error: 'Tenant not found' });
328 const tenant = tenantQuery.rows[0];
330 // Get stats for this tenant
331 const statsQuery = await pool.query(`
333 (SELECT COUNT(*) FROM users WHERE tenant_id = $1) as user_count,
334 (SELECT COUNT(*) FROM customers WHERE tenant_id = $1) as customer_count,
335 (SELECT COUNT(*) FROM tickets WHERE tenant_id = $1) as ticket_count,
336 (SELECT COUNT(*) FROM invoices WHERE tenant_id = $1) as invoice_count,
337 (SELECT COUNT(*) FROM contracts WHERE tenant_id = $1) as contract_count,
338 (SELECT COUNT(*) FROM agents WHERE tenant_id = $1) as agent_count
343 stats: statsQuery.rows[0]
346 console.error('Error fetching tenant:', err);
347 res.status(500).json({ error: 'Failed to fetch tenant' });
352 * @api {post} /tenants Create Tenant
353 * @apiName CreateTenant
355 * @apiDescription Creates a new tenant with automatic subdomain provisioning, DNS record creation,
356 * MeshCentral device group setup, and audit logging. Root MSP administrators only. Comprehensive
357 * tenant onboarding in single operation.
359 * **Creation Process:**
360 * 1. Validate name and subdomain (format, uniqueness, reserved words)
361 * 2. Create tenant record with 'active' status
362 * 3. Initialize tenant_settings with company_name
363 * 4. Create MeshCentral device group for RMM
364 * 5. Provision Cloudflare DNS CNAME record (subdomain → base domain)
365 * 6. Log creation to tenant_audit_log
366 * 7. Return complete tenant object with subdomain_url
368 * **Subdomain Validation:**
369 * - Format: lowercase letters, numbers, hyphens only (regex: ^[a-z0-9][a-z0-9-]*[a-z0-9]$)
370 * - Reserved: admin, api, www, app, mail, support, help
371 * - Uniqueness: Must not exist in tenants table
372 * - Special case: 'root' subdomain forced to 'root.demo', only one root tenant allowed
374 * **MeshCentral Integration:**
375 * - Device group name: "{tenant_name} Devices"
376 * - Group ID stored in meshcentral_group_id column
377 * - Failure non-blocking (marked for retry on next sync)
378 * - Provides device isolation for RMM multi-tenancy
380 * **DNS Provisioning:**
381 * - Cloudflare API creates CNAME: {subdomain}.app.everydaytech.au → app.everydaytech.au
382 * - 2 retry attempts for resilience
383 * - Failure logged but doesn't block tenant creation
384 * - subdomain_url returned if DNS successful, null otherwise
387 * All tenant creations logged with user attribution and details JSON.
388 * @apiHeader {string} Authorization Bearer JWT token (root MSP admin required)
389 * @apiHeader {string} Content-Type application/json
390 * @apiParam {string} name Tenant/company name (required)
391 * @apiParam {string} subdomain Unique subdomain identifier (required, lowercase alphanumeric+hyphens)
392 * @apiSuccess {object} tenant Created tenant object
393 * @apiSuccess {number} tenant.tenant_id Generated tenant ID
394 * @apiSuccess {string} tenant.name Tenant name
395 * @apiSuccess {string} tenant.subdomain Subdomain (may be modified for root tenant)
396 * @apiSuccess {string} tenant.status Always 'active' for new tenants
397 * @apiSuccess {boolean} tenant.is_msp Always false (unless creating root tenant)
398 * @apiSuccess {string} tenant.meshcentral_group_id MeshCentral mesh group ID (if successful)
399 * @apiSuccess {boolean} tenant.meshcentral_setup_complete MeshCentral integration status
400 * @apiSuccess {string} subdomain_url Full URL (https://{subdomain}.app.everydaytech.au) or null
401 * @apiError {number} 400 Name and subdomain are required
402 * @apiError {number} 400 Invalid subdomain format
403 * @apiError {number} 400 This subdomain is reserved
404 * @apiError {Number} 403 Forbidden (not root MSP admin)
405 * @apiError {Number} 409 Root tenant already exists
406 * @apiError {Number} 409 Subdomain already exists
407 * @apiError {Number} 500 Failed to create tenant
409 * @apiExample {curl} Example Request:
411 * -H "Authorization: Bearer eyJhbGc..." \
412 * -H "Content-Type: application/json" \
414 * "name": "Acme Corporation",
415 * "subdomain": "acmecorp"
417 * "https://api.everydaytech.au/tenants"
419 * @apiExample {json} Success Response:
420 * HTTP/1.1 201 Created
424 * "name": "Acme Corporation",
425 * "subdomain": "acmecorp",
426 * "status": "active",
428 * "meshcentral_group_id": "mesh://server/mesh1234567890abcdef",
429 * "meshcentral_setup_complete": true,
430 * "created_at": "2024-03-11T16:45:00.000Z",
431 * "updated_at": "2024-03-11T16:45:00.000Z"
433 * "subdomain_url": "https://acmecorp.app.everydaytech.au"
437 * @see {@link module:routes/tenants.updateTenant} for updating tenants
438 * @see {@link module:routes/tenants.createTenantUser} for creating tenant users
439 * @see {@link module:services/cloudflare} for DNS provisioning
441router.post('/', requireRoot, async (req, res) => {
442 const { name, subdomain } = req.body;
444 if (!name || !subdomain) {
445 return res.status(400).json({ error: 'Name and subdomain are required' });
448 // Check if creating root tenant
449 const isRoot = name?.toLowerCase() === 'root' || subdomain?.toLowerCase() === 'root';
451 // Prevent multiple root tenants
453 const rootCheck = await pool.query(
454 `SELECT tenant_id FROM tenants WHERE LOWER(name) = 'root' OR LOWER(subdomain) = 'root.demo'`
456 if (rootCheck.rows.length > 0) {
457 return res.status(409).json({ error: 'Root tenant already exists' });
461 // Validate subdomain format (lowercase, alphanumeric + hyphens)
462 const subdomainRegex = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
463 if (!subdomainRegex.test(subdomain)) {
464 return res.status(400).json({
465 error: 'Invalid subdomain format. Use lowercase letters, numbers, and hyphens only.'
469 // Reserved subdomains
470 const reserved = ['admin', 'api', 'www', 'app', 'mail', 'support', 'help'];
471 if (reserved.includes(subdomain)) {
472 return res.status(400).json({
473 error: 'This subdomain is reserved'
478 // If creating root tenant, force subdomain to 'root.demo'
479 const finalSubdomain = isRoot ? 'root.demo' : subdomain;
480 const result = await pool.query(
481 `INSERT INTO tenants (name, subdomain, status, is_msp)
482 VALUES ($1, $2, 'active', false)
484 [name, finalSubdomain]
487 const newTenantId = result.rows[0].tenant_id;
489 // Initialize tenant settings with tenant name as company name
492 `INSERT INTO tenant_settings (tenant_id, setting_key, setting_value, description)
493 VALUES ($1, 'company_name', $2, 'Company name for this tenant')
494 ON CONFLICT (tenant_id, setting_key) DO NOTHING`,
497 } catch (settingsErr) {
498 console.error('Warning: Failed to initialize tenant settings:', settingsErr);
499 // Don't fail the whole operation if settings initialization fails
502 // Automatically create MeshCentral device group
504 const MeshCentralAPI = require('../lib/meshcentral-api');
505 const meshAPI = new MeshCentralAPI({
506 url: process.env.MESHCENTRAL_URL || 'https://rmm-psa-meshcentral-aq48h.ondigitalocean.app',
507 username: process.env.MESHCENTRAL_ADMIN_USER || 'admin',
508 password: process.env.MESHCENTRAL_ADMIN_PASS || 'admin'
511 await meshAPI.login();
512 const meshName = `${name} Devices`;
513 const meshResult = await meshAPI.createMesh(
515 `Device group for ${name} (tenant:${newTenantId})`
518 if (meshResult && meshResult.meshId) {
519 // Update tenant with mesh group ID
521 'UPDATE tenants SET meshcentral_group_id = $1, meshcentral_setup_complete = true WHERE tenant_id = $2',
522 [meshResult.meshId, newTenantId]
524 console.log(`✅ Created MeshCentral mesh group ${meshResult.meshId} for tenant ${name}`);
527 console.error('⚠️ Failed to create MeshCentral mesh group:', meshErr.message);
528 // Don't fail tenant creation if mesh creation fails - will be created on next sync
533 `INSERT INTO tenant_audit_log (user_id, tenant_id, action, resource_type, details)
534 VALUES ($1, $2, 'create', 'tenant', $3)`,
538 JSON.stringify({ name, subdomain })
542 // Create subdomain in Cloudflare
543 const target = process.env.BASE_APP_DOMAIN || 'app.everydayoffice.au';
544 let dnsRecord = null;
546 // DNS provisioning with retry
547 for (let attempt = 1; attempt <= 2; attempt++) {
549 dnsRecord = await createSubdomain(finalSubdomain, target);
550 console.log(`Tenant domain ready: ${dnsRecord}`);
554 console.error(`⚠️ Failed to provision DNS (attempt ${attempt}):`, cfErr.message);
556 // Optionally: notify admin or mark tenant for DNS retry
561 // Fetch the updated tenant record with mesh group ID
562 const updatedTenant = await pool.query(
563 'SELECT * FROM tenants WHERE tenant_id = $1',
567 res.status(201).json({
568 tenant: updatedTenant.rows[0],
569 subdomain_url: dnsRecord ? `https://${dnsRecord}` : null
572 console.error('Error creating tenant:', err);
574 if (err.code === '23505') { // Unique violation
575 return res.status(409).json({ error: 'Subdomain already exists' });
578 res.status(500).json({ error: 'Failed to create tenant' });
583 * @api {put} /tenants/:id Update Tenant
584 * @apiName UpdateTenant
586 * @apiDescription Updates tenant name and/or status. Root MSP administrators only. Subdomain
587 * cannot be changed after creation to prevent breaking existing users and links. All updates
588 * logged to audit trail.
590 * **Updateable Fields:**
591 * - name: Tenant/company name (shown in UI, reports)
592 * - status: Operational status (active, suspended, inactive)
595 * - active: Full access to platform features
596 * - suspended: Limited access (e.g., billing issues, policy violation)
597 * - inactive: Soft delete, data retained but no access
599 * **Immutable Fields:**
600 * - subdomain: Cannot be changed after creation (would break links, user accounts)
601 * - tenant_id: System-generated identifier
602 * - is_msp: Changed via dedicated /msp endpoint
603 * - meshcentral_group_id: System-managed
606 * All updates logged to tenant_audit_log with user attribution and JSON details.
607 * @apiHeader {string} Authorization Bearer JWT token (root MSP admin required)
608 * @apiHeader {string} Content-Type application/json
609 * @apiParam {number} id Tenant ID (in URL path)
610 * @apiParam {string} [name] New tenant/company name (optional)
611 * @apiParam {string} [status] New status: active, suspended, or inactive (optional)
612 * @apiSuccess {number} tenant_id Tenant ID
613 * @apiSuccess {string} name Updated tenant name
614 * @apiSuccess {string} subdomain Subdomain (unchanged)
615 * @apiSuccess {string} status Updated status
616 * @apiSuccess {string} updated_at Last update timestamp (set to NOW())
617 * @apiError {number} 400 Status must be one of: active, suspended, inactive
618 * @apiError {number} 403 Forbidden (not root MSP admin)
619 * @apiError {number} 404 Tenant not found
620 * @apiError {number} 500 Failed to update tenant
621 * @apiExample {curl} Example Request (Update Status):
623 * -H "Authorization: Bearer eyJhbGc..." \
624 * -H "Content-Type: application/json" \
625 * -d '{"status": "suspended"}' \
626 * "https://api.everydaytech.au/tenants/5"
627 * @apiExample {curl} Example Request (Update Name and Status):
629 * -H "Authorization: Bearer eyJhbGc..." \
630 * -H "Content-Type: application/json" \
632 * "name": "Acme Corporation (EMEA)",
635 * "https://api.everydaytech.au/tenants/5"
637 * @apiExample {json} Success Response:
641 * "name": "Acme Corporation (EMEA)",
642 * "subdomain": "acmecorp",
643 * "status": "active",
645 * "updated_at": "2024-03-11T17:00:00.000Z"
649 * @see {@link module:routes/tenants.createTenant} for creating tenants
650 * @see {@link module:routes/tenants.updateMSPStatus} for changing MSP status
652router.put('/:id', requireRoot, async (req, res) => {
653 const { id } = req.params;
654 const { name, status } = req.body;
656 // Prevent changing subdomain after creation (would break existing users/links)
657 const allowedStatuses = ['active', 'suspended', 'inactive'];
658 if (status && !allowedStatuses.includes(status)) {
659 return res.status(400).json({
660 error: `Status must be one of: ${allowedStatuses.join(', ')}`
665 const result = await pool.query(
667 SET name = COALESCE($1, name),
668 status = COALESCE($2, status),
675 if (result.rows.length === 0) {
676 return res.status(404).json({ error: 'Tenant not found' });
681 `INSERT INTO tenant_audit_log (user_id, tenant_id, action, resource_type, details)
682 VALUES ($1, $2, 'update', 'tenant', $3)`,
686 JSON.stringify({ name, status })
690 res.json(result.rows[0]);
692 console.error('Error updating tenant:', err);
693 res.status(500).json({ error: 'Failed to update tenant' });
698 * @api {get} /tenants/:id/audit Get Tenant Audit Log
699 * @apiName GetTenantAudit
701 * @apiDescription Retrieves paginated audit log for a specific tenant. Shows all actions performed
702 * on tenant (create, update, delete, user creation, MSP status changes) with user attribution
703 * and detailed change tracking. Root MSP administrators only.
705 * **Logged Actions:**
706 * - create: Tenant creation
707 * - update: Name/status changes
708 * - delete: Tenant deletion
709 * - create (user): User added to tenant
710 * - update_msp_status: MSP flag toggled
713 * - action: Operation type
714 * - resource_type: 'tenant' or 'user'
715 * - resource_id: ID of affected resource (user_id for user operations)
716 * - details: JSON payload with change details
717 * - user_name, user_email: Who performed action (from users table join)
718 * - created_at: When action occurred
721 * - Default: 50 records per page
722 * - Sorted by created_at DESC (newest first)
723 * - Total count provided for pagination UI
724 * @apiHeader {string} Authorization Bearer JWT token (root MSP admin required)
725 * @apiParam {number} id Tenant ID (in URL path)
726 * @apiParam {number} [limit=50] Records per page (max: system-defined)
727 * @apiParam {number} [page=1] Page number (1-indexed)
728 * @apiSuccess {object[]} logs Array of audit log entries
729 * @apiSuccess {number} logs.id Audit log entry ID
730 * @apiSuccess {number} logs.user_id User who performed action
731 * @apiSuccess {number} logs.tenant_id Tenant ID
732 * @apiSuccess {string} logs.action Action type
733 * @apiSuccess {string} logs.resource_type Resource type (tenant, user)
734 * @apiSuccess {number} logs.resource_id Resource ID (if applicable)
735 * @apiSuccess {Object} logs.details JSON details of action/changes
736 * @apiSuccess {string} logs.created_at Timestamp when action occurred
737 * @apiSuccess {string} logs.user_name Name of user who performed action
738 * @apiSuccess {string} logs.user_email Email of user who performed action
739 * @apiSuccess {number} total Total count of audit records for this tenant
740 * @apiError {number} 403 Forbidden (not root MSP admin)
741 * @apiError {Number} 500 Failed to fetch audit log
743 * @apiExample {curl} Example Request:
745 * -H "Authorization: Bearer eyJhbGc..." \
746 * "https://api.everydaytech.au/tenants/5/audit?limit=50&page=1"
748 * @apiExample {json} Success Response:
756 * "action": "update",
757 * "resource_type": "tenant",
758 * "resource_id": null,
759 * "details": {"name": "Acme Corporation (EMEA)", "status": "active"},
760 * "created_at": "2024-03-11T17:00:00.000Z",
761 * "user_name": "Admin User",
762 * "user_email": "admin@everydaytech.au"
768 * "action": "create",
769 * "resource_type": "user",
771 * "details": {"username": "jdoe", "email": "jdoe@acme.com", "role": "admin"},
772 * "created_at": "2024-02-15T14:30:00.000Z",
773 * "user_name": "Admin User",
774 * "user_email": "admin@everydaytech.au"
781 * @see {@link module:routes/tenants.getImpersonationAudit} for impersonation audit trail
783router.get('/:id/audit', requireRoot, async (req, res) => {
784 const { id } = req.params;
785 const limit = parseInt(req.query.limit) || 50;
786 const page = parseInt(req.query.page) || 1;
787 const offset = (page - 1) * limit;
790 const result = await pool.query(
794 u.email as user_email
795 FROM tenant_audit_log a
796 LEFT JOIN users u ON a.user_id = u.user_id
797 WHERE a.tenant_id = $1
798 ORDER BY a.created_at DESC
803 const countResult = await pool.query(
804 'SELECT COUNT(*) FROM tenant_audit_log WHERE tenant_id = $1',
810 total: parseInt(countResult.rows[0].count)
813 console.error('Error fetching audit log:', err);
814 res.status(500).json({ error: 'Failed to fetch audit log' });
819 * @api {post} /tenants/:id/users Create Tenant User
820 * @apiName CreateTenantUser
822 * @apiDescription Creates a new user account within a specific tenant. Root MSP administrators only.
823 * Allows MSP to provision user accounts for customer tenants. Password hashed with bcrypt (10 rounds).
824 * User creation logged to tenant audit trail.
827 * - owner: Full administrative access (billing, user management, all features)
828 * - admin: Administrative access (user management, most features)
829 * - agent: Standard technician access (tickets, customers, limited admin)
830 * - read: Read-only access (reporting, viewing only)
832 * **Password Security:**
833 * - Bcrypt hashing with 10-round salt
834 * - Plain text password never stored
835 * - User can change password via /users/me/password endpoint
838 * - Username, email, password required
839 * - Email must be unique across all tenants
840 * - Username must be unique within tenant
841 * - Role validated against allowed roles
844 * User creation logged to tenant_audit_log with username, email, role in JSON details.
845 * @apiHeader {string} Authorization Bearer JWT token (root MSP admin required)
846 * @apiHeader {string} Content-Type application/json
847 * @apiParam {number} id Tenant ID (in URL path)
848 * @apiParam {string} username Username (required, unique within tenant)
849 * @apiParam {string} email Email address (required, unique globally)
850 * @apiParam {string} password Plain text password (required, will be hashed)
851 * @apiParam {string} [role=agent] User role: owner, admin, agent, or read (default: agent)
852 * @apiSuccess {number} user_id Generated user ID
853 * @apiSuccess {number} tenant_id Tenant ID
854 * @apiSuccess {string} username Username
855 * @apiSuccess {string} email Email address
856 * @apiParam {string} role Assigned role
857 * @apiSuccess {string} created_at User creation timestamp
858 * @apiError {number} 400 Username, email, and password are required
859 * @apiError {number} 400 Role must be one of: owner, admin, agent, read
860 * @apiError {number} 403 Forbidden (not root MSP admin)
861 * @apiError {Number} 404 Tenant not found
862 * @apiError {Number} 409 Username or email already exists
863 * @apiError {Number} 500 Failed to create user
865 * @apiExample {curl} Example Request:
867 * -H "Authorization: Bearer eyJhbGc..." \
868 * -H "Content-Type: application/json" \
870 * "username": "jdoe",
871 * "email": "jdoe@acme.com",
872 * "password": "SecurePass123!",
875 * "https://api.everydaytech.au/tenants/5/users"
877 * @apiExample {json} Success Response:
878 * HTTP/1.1 201 Created
882 * "username": "jdoe",
883 * "email": "jdoe@acme.com",
885 * "created_at": "2024-03-11T17:15:00.000Z"
889 * @see {@link module:routes/users} for user management API
890 * @see {@link module:routes/tenants.getTenant} for tenant details with user count
892router.post('/:id/users', requireRoot, async (req, res) => {
893 const { id } = req.params;
894 const { username, email, password, role } = req.body;
896 if (!username || !email || !password) {
897 return res.status(400).json({ error: 'Username, email, and password are required' });
900 const allowedRoles = ['owner', 'admin', 'agent', 'read'];
901 const userRole = role || 'agent';
903 if (!allowedRoles.includes(userRole)) {
904 return res.status(400).json({
905 error: `Role must be one of: ${allowedRoles.join(', ')}`
910 // Verify tenant exists
911 const tenantCheck = await pool.query(
912 'SELECT tenant_id FROM tenants WHERE tenant_id = $1',
916 if (tenantCheck.rows.length === 0) {
917 return res.status(404).json({ error: 'Tenant not found' });
920 const bcrypt = require('bcrypt');
921 const hashedPassword = await bcrypt.hash(password, 10);
923 const result = await pool.query(
924 `INSERT INTO users (tenant_id, username, email, password, role)
925 VALUES ($1, $2, $3, $4, $5)
926 RETURNING user_id, tenant_id, username, email, role, created_at`,
927 [id, username, email, hashedPassword, userRole]
932 `INSERT INTO tenant_audit_log (user_id, tenant_id, action, resource_type, resource_id, details)
933 VALUES ($1, $2, 'create', 'user', $3, $4)`,
937 result.rows[0].user_id,
938 JSON.stringify({ username, email, role: userRole })
942 res.status(201).json(result.rows[0]);
944 console.error('Error creating user:', err);
946 if (err.code === '23505') { // Unique violation
947 return res.status(409).json({ error: 'Username or email already exists' });
950 res.status(500).json({ error: 'Failed to create user' });
955 * @api {put} /tenants/:id/msp Update MSP Status
956 * @apiName UpdateMSPStatus
958 * @apiDescription Toggles MSP (Managed Service Provider) status for a tenant. Root MSP administrators
959 * only. MSP flag grants elevated privileges including multi-tenant visibility and impersonation.
960 * Use with extreme caution - MSP tenants have platform-wide access.
962 * **MSP Privileges:**
963 * - View/manage all tenants' data (customers, tickets, invoices, etc.)
964 * - Impersonate users in other tenants
965 * - Create and delete tenants
966 * - Platform-wide reporting and analytics
967 * - Root-level administrative functions
969 * **Security Warning:**
970 * Only grant MSP status to trusted platform administrators. MSP users bypass tenant isolation
971 * and can access all data across the platform.
974 * MSP status changes logged to tenant_audit_log with details JSON showing new is_msp value.
975 * @apiHeader {string} Authorization Bearer JWT token (root MSP admin required)
976 * @apiHeader {string} Content-Type application/json
977 * @apiParam {number} id Tenant ID (in URL path)
978 * @apiParam {boolean} is_msp New MSP status (required)
979 * @apiSuccess {number} tenant_id Tenant ID
980 * @apiSuccess {string} name Tenant name
981 * @apiSuccess {boolean} is_msp Updated MSP status
982 * @apiSuccess {string} updated_at Last update timestamp
983 * @apiError {number} 403 Forbidden (not root MSP admin)
984 * @apiError {number} 404 Tenant not found
985 * @apiError {number} 500 Failed to update MSP status
986 * @apiExample {curl} Example Request (Grant MSP):
988 * -H "Authorization: Bearer eyJhbGc..." \
989 * -H "Content-Type: application/json" \
990 * -d '{"is_msp": true}' \
991 * "https://api.everydaytech.au/tenants/5/msp"
992 * @apiExample {curl} Example Request (Revoke MSP):
994 * -H "Authorization: Bearer eyJhbGc..." \
995 * -H "Content-Type: application/json" \
996 * -d '{"is_msp": false}' \
997 * "https://api.everydaytech.au/tenants/5/msp"
998 * @apiExample {json} Success Response:
1002 * "name": "Acme Corporation",
1003 * "subdomain": "acmecorp",
1005 * "updated_at": "2024-03-11T17:30:00.000Z"
1008 * @see {@link module:routes/auth.impersonate} for MSP tenant impersonation
1009 * @see {@link module:routes/tenants.updateTenant} for general tenant updates
1011router.put('/:id/msp', requireRoot, async (req, res) => {
1012 const { id } = req.params;
1013 const { is_msp } = req.body;
1015 if (typeof is_msp !== 'boolean') {
1016 return res.status(400).json({ error: 'is_msp must be a boolean value' });
1020 const result = await pool.query(
1022 SET is_msp = $1, updated_at = NOW()
1023 WHERE tenant_id = $2
1028 if (result.rows.length === 0) {
1029 return res.status(404).json({ error: 'Tenant not found' });
1034 `INSERT INTO tenant_audit_log (user_id, tenant_id, action, resource_type, details)
1035 VALUES ($1, $2, 'update_msp_status', 'tenant', $3)`,
1039 JSON.stringify({ is_msp })
1043 res.json(result.rows[0]);
1045 console.error('Error updating MSP status:', err);
1046 res.status(500).json({ error: 'Failed to update MSP status' });
1051 * @api {delete} /tenants/:id Delete Tenant
1052 * @apiName DeleteTenant
1054 * @apiDescription Permanently deletes a tenant. Root MSP administrators only. Prevents deletion of
1055 * MSP tenants. By default, requires tenant to have no related data (users, customers, tickets, etc.).
1056 * Use ?force=true query parameter to cascade delete all related data.
1058 * **Deletion Safety:**
1059 * - Cannot delete root/MSP tenants (prevents platform lockout)
1060 * - Default: Blocks deletion if any related data exists
1061 * - Force mode: Cascade deletes all related data in proper order
1062 * - Always deletes audit logs and notifications (non-critical data)
1064 * **Related Data Check:**
1065 * System counts records in:
1066 * - users, customers, tickets, invoices, contracts
1067 * - agents (RMM), domains, purchase_orders
1069 * If ANY count > 0 and force != 'true', deletion blocked with detailed error showing counts.
1071 * **Force Delete Cascade Order:**
1072 * 1. Tickets and attachments/comments
1073 * 2. Invoices and line items
1074 * 3. Purchase orders and lines
1075 * 4. Contracts and line items
1076 * 5. Domains and DNS records
1077 * 6. Agents and customers
1078 * 7. Products and quotes
1079 * 8. Users (last, due to foreign key dependencies)
1082 * **Transaction Safety:**
1083 * All operations in database transaction. Rollback on any error prevents partial deletions.
1084 * @apiHeader {string} Authorization Bearer JWT token (root MSP admin required)
1085 * @apiParam {number} id Tenant ID (in URL path)
1086 * @apiParam {boolean} [force=false] Query parameter: ?force=true to cascade delete all data
1087 * @apiSuccess {string} message Success message
1088 * @apiSuccess {object} tenant Deleted tenant object
1089 * @apiSuccess {boolean} cascade_deleted Whether force delete was used
1090 * @apiError {number} 400 Cannot delete root/MSP tenant
1091 * @apiError {number} 400 Cannot delete tenant with related data (without force)
1092 * @apiError {number} 403 Forbidden (not root MSP admin)
1093 * @apiError {number} 404 Tenant not found
1094 * @apiError {number} 500 Failed to delete tenant
1095 * @apiExample {curl} Example Request (Safe Delete):
1097 * -H "Authorization: Bearer eyJhbGc..." \
1098 * "https://api.everydaytech.au/tenants/5"
1099 * @apiExample {curl} Example Request (Force Delete):
1101 * -H "Authorization: Bearer eyJhbGc..." \
1102 * "https://api.everydaytech.au/tenants/5?force=true"
1103 * @apiExample {json} Success Response:
1106 * "message": "Tenant deleted successfully",
1109 * "name": "Acme Corporation",
1110 * "subdomain": "acmecorp"
1112 * "cascade_deleted": true
1114 * @apiExample {json} Error Response (Has Data):
1115 * HTTP/1.1 400 Bad Request
1117 * "error": "Cannot delete tenant with related data",
1119 * "user_count": "23",
1120 * "customer_count": "145",
1121 * "ticket_count": "892",
1122 * "invoice_count": "1247",
1123 * "agent_count": "432",
1124 * "contract_count": "89",
1125 * "domain_count": "15",
1126 * "purchase_order_count": "34"
1128 * "message": "Please delete all users, customers, tickets, invoices, contracts, agents, domains, and purchase orders first. Or use ?force=true to cascade delete all data."
1130 * @apiExample {json} Error Response (MSP Tenant):
1131 * HTTP/1.1 400 Bad Request
1133 * "error": "Cannot delete root/MSP tenant",
1134 * "message": "The MSP tenant cannot be deleted as it manages the system"
1138 * @see {@link module:routes/tenants.updateTenant} for suspending tenants (soft delete alternative)
1140router.delete('/:id', requireRoot, async (req, res) => {
1141 const { id } = req.params;
1142 const { force } = req.query; // Allow ?force=true to delete with related data
1144 const client = await pool.connect();
1146 await client.query('BEGIN');
1148 // Check if tenant exists
1149 const checkQuery = await client.query(
1150 'SELECT is_msp, name FROM tenants WHERE tenant_id = $1',
1154 if (checkQuery.rows.length === 0) {
1155 await client.query('ROLLBACK');
1156 return res.status(404).json({ error: 'Tenant not found' });
1159 const tenant = checkQuery.rows[0];
1161 // Prevent deleting root/MSP tenant
1162 if (tenant.is_msp) {
1163 await client.query('ROLLBACK');
1164 return res.status(400).json({
1165 error: 'Cannot delete root/MSP tenant',
1166 message: 'The MSP tenant cannot be deleted as it manages the system'
1170 // Always delete audit log and notifications (they're just logging/notification data)
1171 await client.query('DELETE FROM tenant_audit_log WHERE tenant_id = $1', [id]);
1172 await client.query('DELETE FROM notifications WHERE tenant_id = $1', [id]);
1174 // Check for related data
1175 const relatedCheck = await client.query(`
1177 (SELECT COUNT(*) FROM users WHERE tenant_id = $1) as user_count,
1178 (SELECT COUNT(*) FROM customers WHERE tenant_id = $1) as customer_count,
1179 (SELECT COUNT(*) FROM tickets WHERE tenant_id = $1) as ticket_count,
1180 (SELECT COUNT(*) FROM invoices WHERE tenant_id = $1) as invoice_count,
1181 (SELECT COUNT(*) FROM agents WHERE tenant_id = $1) as agent_count,
1182 (SELECT COUNT(*) FROM contracts WHERE tenant_id = $1) as contract_count,
1183 (SELECT COUNT(*) FROM domains WHERE tenant_id = $1) as domain_count,
1184 (SELECT COUNT(*) FROM purchase_orders WHERE tenant_id = $1) as purchase_order_count
1187 const counts = relatedCheck.rows[0];
1188 const hasRelatedData = Object.values(counts).some(count => parseInt(count) > 0);
1190 if (hasRelatedData && force !== 'true') {
1191 await client.query('ROLLBACK');
1192 return res.status(400).json({
1193 error: 'Cannot delete tenant with related data',
1195 message: 'Please delete all users, customers, tickets, invoices, contracts, agents, domains, and purchase orders first. Or use ?force=true to cascade delete all data.'
1199 // If force=true, cascade delete all related data
1200 if (force === 'true') {
1201 console.log(`[Tenant Delete] Force deleting tenant ${id} with all related data...`);
1203 // Delete in order to respect foreign keys
1204 // Tickets and related
1205 await client.query('DELETE FROM ticket_comments WHERE tenant_id = $1', [id]);
1206 await client.query('DELETE FROM ticket_attachments WHERE tenant_id = $1', [id]);
1207 await client.query('DELETE FROM tickets WHERE tenant_id = $1', [id]);
1209 // Invoices and related
1210 await client.query('DELETE FROM invoice_items WHERE tenant_id = $1', [id]);
1211 await client.query('DELETE FROM invoices WHERE tenant_id = $1', [id]);
1214 await client.query('DELETE FROM purchase_order_lines WHERE tenant_id = $1', [id]);
1215 await client.query('DELETE FROM purchase_orders WHERE tenant_id = $1', [id]);
1218 await client.query('DELETE FROM contract_items WHERE tenant_id = $1', [id]);
1219 await client.query('DELETE FROM contracts WHERE tenant_id = $1', [id]);
1222 await client.query('DELETE FROM dns_records WHERE tenant_id = $1', [id]);
1223 await client.query('DELETE FROM domains WHERE tenant_id = $1', [id]);
1224 await client.query('DELETE FROM domain_registration_requests WHERE tenant_id = $1', [id]);
1226 // Agents and customers (before products/quotes since they may reference products)
1227 await client.query('DELETE FROM agents WHERE tenant_id = $1', [id]);
1228 await client.query('DELETE FROM customers WHERE tenant_id = $1', [id]);
1230 // Products and quotes (after customers)
1231 await client.query('DELETE FROM quote_items WHERE tenant_id = $1', [id]);
1232 await client.query('DELETE FROM quotes WHERE tenant_id = $1', [id]);
1233 await client.query('DELETE FROM products WHERE tenant_id = $1', [id]);
1236 await client.query('DELETE FROM users WHERE tenant_id = $1', [id]);
1238 console.log(`[Tenant Delete] Deleted all related data for tenant ${id}`);
1242 const result = await client.query(
1243 'DELETE FROM tenants WHERE tenant_id = $1 RETURNING *',
1247 await client.query('COMMIT');
1250 message: 'Tenant deleted successfully',
1251 tenant: result.rows[0],
1252 cascade_deleted: force === 'true'
1255 await client.query('ROLLBACK');
1256 console.error('Error deleting tenant:', err);
1257 res.status(500).json({
1258 error: 'Failed to delete tenant',
1259 details: err.message,
1260 constraint: err.constraint || null
1268 * @api {get} /tenants/:id/impersonation-audit Get Impersonation Audit Log
1269 * @apiName GetImpersonationAudit
1271 * @apiDescription Retrieves impersonation audit log for a specific tenant. Shows all times MSP
1272 * administrators impersonated users within this tenant. Root MSP administrators only.
1273 * **Currently not implemented** - returns placeholder response.
1275 * **Future Implementation:**
1277 * - Who performed impersonation (admin user_id, email)
1278 * - Which user was impersonated (impersonated_user_id)
1279 * - Which tenant was accessed (impersonated_tenant_id)
1280 * - Start timestamp (when impersonation began)
1281 * - End timestamp (when exited impersonation)
1282 * - Actions performed during impersonation (optional detailed logging)
1285 * - Security auditing of MSP administrator actions
1286 * - Compliance reporting (who accessed customer data)
1287 * - Incident investigation (trace actions to specific admin)
1288 * - Customer transparency (show support session history)
1291 * When implemented, will support limit/offset pagination similar to tenant audit log.
1292 * @apiHeader {string} Authorization Bearer JWT token (root MSP admin required)
1293 * @apiParam {number} id Tenant ID (in URL path)
1294 * @apiParam {number} [limit=50] Records per page (when implemented)
1295 * @apiParam {number} [offset=0] Offset for pagination (when implemented)
1296 * @apiSuccess {object[]} logs Empty array (placeholder)
1297 * @apiSuccess {string} message "Impersonation audit log not yet implemented"
1298 * @apiError {number} 403 Forbidden (not root MSP admin)
1299 * @apiError {number} 500 Failed to fetch impersonation audit log
1300 * @apiExample {curl} Example Request:
1302 * -H "Authorization: Bearer eyJhbGc..." \
1303 * "https://api.everydaytech.au/tenants/5/impersonation-audit?limit=50&offset=0"
1304 * @apiExample {json} Current Response (Not Implemented):
1308 * "message": "Impersonation audit log not yet implemented"
1310 * @apiExample {json} Future Response (When Implemented):
1316 * "admin_user_id": 1,
1317 * "admin_email": "admin@everydaytech.au",
1318 * "impersonated_user_id": 23,
1319 * "impersonated_email": "jdoe@acme.com",
1320 * "impersonated_tenant_id": 5,
1321 * "start_timestamp": "2024-03-11T14:30:00.000Z",
1322 * "end_timestamp": "2024-03-11T14:45:00.000Z",
1323 * "duration_minutes": 15
1327 * @since 1.0.0 (endpoint exists, feature not implemented)
1328 * @see {@link module:routes/auth.impersonate} for impersonation feature
1329 * @see {@link module:routes/tenants.getTenantAudit} for general tenant audit log
1331router.get('/:id/impersonation-audit', requireRoot, async (req, res) => {
1332 const { id } = req.params;
1333 const limit = parseInt(req.query.limit) || 50;
1334 const offset = parseInt(req.query.offset) || 0;
1337 // TODO: Implement when impersonation_audit table is created
1338 res.json({ logs: [], message: 'Impersonation audit log not yet implemented' });
1340 // const result = await pool.query(
1341 // `SELECT * FROM impersonation_audit
1342 // WHERE impersonated_tenant_id = $1
1343 // ORDER BY timestamp DESC
1344 // LIMIT $2 OFFSET $3`,
1345 // [id, limit, offset]
1347 // res.json({ logs: result.rows });
1349 console.error('Error fetching impersonation audit log:', err);
1350 res.status(500).json({ error: 'Failed to fetch impersonation audit log' });
1354module.exports = router;