EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
tenants.js
Go to the documentation of this file.
1/**
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.
7 *
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
13 *
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
20 *
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
27 *
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
34 *
35 * **Audit Logging:**
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)
41 *
42 * **Security:**
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
48 *
49 * **Related Models:**
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)
54 * @requires express
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
61 * @date 2024-03-11
62 * @since 1.0.0
63 * @see {@link module:routes/users} for user management
64 * @see {@link module:middleware/tenant} for tenant context filtering
65 */
66
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');
73
74// Apply tenant context to all routes in this router
75router.use(authenticateToken, setTenantContext);
76
77/**
78 * @api {get} /tenants/current Get Current Tenant
79 * @apiName GetCurrentTenant
80 * @apiGroup Tenants
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.).
83 *
84 * **Use Cases:**
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
89 *
90 * **Tenant Context:**
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:
106 * curl -X GET \
107 * -H "Authorization: Bearer eyJhbGc..." \
108 * "https://api.everydaytech.au/tenants/current"
109 * @apiExample {json} Success Response:
110 * HTTP/1.1 200 OK
111 * {
112 * "tenant_id": 5,
113 * "name": "Acme Corporation",
114 * "subdomain": "acmecorp",
115 * "status": "active",
116 * "is_msp": false,
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"
121 * }
122 * @since 1.0.0
123 * @see {@link module:routes/tenants.getTenant} for detailed tenant stats
124 */
125router.get('/current', async (req, res) => {
126 try {
127 const result = await pool.query(
128 'SELECT * FROM tenants WHERE tenant_id = $1',
129 [req.user.tenantId]
130 );
131
132 if (result.rows.length === 0) {
133 return res.status(404).json({ error: 'Tenant not found' });
134 }
135
136 res.json(result.rows[0]);
137 } catch (err) {
138 console.error('Error fetching current tenant:', err);
139 res.status(500).json({ error: 'Failed to fetch tenant' });
140 }
141});
142
143/**
144 * @api {get} /tenants List All Tenants
145 * @apiName GetTenants
146 * @apiGroup Tenants
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.
150 *
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
155 *
156 * **Ordering:**
157 * Tenants sorted by creation date (newest first). Shows recent tenant additions at top.
158 *
159 * **Access Control:**
160 * Requires Root MSP authentication (requireRoot middleware). Regular tenant users receive
161 * 403 Forbidden.
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:
177 * curl -X GET \
178 * -H "Authorization: Bearer eyJhbGc..." \
179 * "https://api.everydaytech.au/tenants"
180 * @apiExample {json} Success Response:
181 * HTTP/1.1 200 OK
182 * {
183 * "tenants": [
184 * {
185 * "tenant_id": 5,
186 * "name": "Acme Corporation",
187 * "subdomain": "acmecorp",
188 * "status": "active",
189 * "is_msp": false,
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"
195 * },
196 * {
197 * "tenant_id": 1,
198 * "name": "Root MSP",
199 * "subdomain": "root.demo",
200 * "status": "active",
201 * "is_msp": true,
202 * "meshcentral_group_id": null,
203 * "created_at": "2023-11-01T00:00:00.000Z",
204 * "user_count": "5",
205 * "customer_count": "0",
206 * "ticket_count": "0"
207 * }
208 * ]
209 * }
210 * @since 1.0.0
211 * @see {@link module:routes/tenants.getTenant} for detailed single tenant info
212 * @see {@link module:routes/tenants.createTenant} for creating tenants
213 */
214router.get('/', requireRoot, async (req, res) => {
215 try {
216 const result = await pool.query(`
217 SELECT
218 t.*,
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
222 FROM tenants t
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
226 GROUP BY t.tenant_id
227 ORDER BY t.created_at DESC
228 `);
229
230 res.json({ tenants: result.rows });
231 } catch (err) {
232 console.error('Error fetching tenants:', err);
233 res.status(500).json({ error: 'Failed to fetch tenants' });
234 }
235});
236
237/**
238 * @api {get} /tenants/:id Get Tenant Details
239 * @apiName GetTenant
240 * @apiGroup Tenants
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.
244 *
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
252 *
253 * **Use Cases:**
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
277 *
278 * @apiError {Number} 403 Forbidden (not root MSP admin)
279 * @apiError {Number} 404 Tenant not found
280 * @apiError {Number} 500 Failed to fetch tenant
281 *
282 * @apiExample {curl} Example Request:
283 * curl -X GET \
284 * -H "Authorization: Bearer eyJhbGc..." \
285 * "https://api.everydaytech.au/tenants/5"
286 *
287 * @apiExample {json} Success Response:
288 * HTTP/1.1 200 OK
289 * {
290 * "tenant": {
291 * "tenant_id": 5,
292 * "name": "Acme Corporation",
293 * "subdomain": "acmecorp",
294 * "status": "active",
295 * "is_msp": false,
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"
300 * },
301 * "stats": {
302 * "user_count": "23",
303 * "customer_count": "145",
304 * "ticket_count": "892",
305 * "invoice_count": "1247",
306 * "contract_count": "89",
307 * "agent_count": "432"
308 * }
309 * }
310 *
311 * @since 1.0.0
312 * @see {@link module:routes/tenants.getTenants} for list of all tenants
313 * @see {@link module:routes/tenants.updateTenant} for updating tenant
314 */
315router.get('/:id', requireRoot, async (req, res) => {
316 const { id } = req.params;
317
318 try {
319 const tenantQuery = await pool.query(
320 'SELECT * FROM tenants WHERE tenant_id = $1',
321 [id]
322 );
323
324 if (tenantQuery.rows.length === 0) {
325 return res.status(404).json({ error: 'Tenant not found' });
326 }
327
328 const tenant = tenantQuery.rows[0];
329
330 // Get stats for this tenant
331 const statsQuery = await pool.query(`
332 SELECT
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
339 `, [id]);
340
341 res.json({
342 tenant,
343 stats: statsQuery.rows[0]
344 });
345 } catch (err) {
346 console.error('Error fetching tenant:', err);
347 res.status(500).json({ error: 'Failed to fetch tenant' });
348 }
349});
350
351/**
352 * @api {post} /tenants Create Tenant
353 * @apiName CreateTenant
354 * @apiGroup Tenants
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.
358 *
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
367 *
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
373 *
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
379 *
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
385 *
386 * **Audit Trail:**
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
408 *
409 * @apiExample {curl} Example Request:
410 * curl -X POST \
411 * -H "Authorization: Bearer eyJhbGc..." \
412 * -H "Content-Type: application/json" \
413 * -d '{
414 * "name": "Acme Corporation",
415 * "subdomain": "acmecorp"
416 * }' \
417 * "https://api.everydaytech.au/tenants"
418 *
419 * @apiExample {json} Success Response:
420 * HTTP/1.1 201 Created
421 * {
422 * "tenant": {
423 * "tenant_id": 5,
424 * "name": "Acme Corporation",
425 * "subdomain": "acmecorp",
426 * "status": "active",
427 * "is_msp": false,
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"
432 * },
433 * "subdomain_url": "https://acmecorp.app.everydaytech.au"
434 * }
435 *
436 * @since 1.0.0
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
440 */
441router.post('/', requireRoot, async (req, res) => {
442 const { name, subdomain } = req.body;
443
444 if (!name || !subdomain) {
445 return res.status(400).json({ error: 'Name and subdomain are required' });
446 }
447
448 // Check if creating root tenant
449 const isRoot = name?.toLowerCase() === 'root' || subdomain?.toLowerCase() === 'root';
450
451 // Prevent multiple root tenants
452 if (isRoot) {
453 const rootCheck = await pool.query(
454 `SELECT tenant_id FROM tenants WHERE LOWER(name) = 'root' OR LOWER(subdomain) = 'root.demo'`
455 );
456 if (rootCheck.rows.length > 0) {
457 return res.status(409).json({ error: 'Root tenant already exists' });
458 }
459 }
460
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.'
466 });
467 }
468
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'
474 });
475 }
476
477 try {
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)
483 RETURNING *`,
484 [name, finalSubdomain]
485 );
486
487 const newTenantId = result.rows[0].tenant_id;
488
489 // Initialize tenant settings with tenant name as company name
490 try {
491 await pool.query(
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`,
495 [newTenantId, name]
496 );
497 } catch (settingsErr) {
498 console.error('Warning: Failed to initialize tenant settings:', settingsErr);
499 // Don't fail the whole operation if settings initialization fails
500 }
501
502 // Automatically create MeshCentral device group
503 try {
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'
509 });
510
511 await meshAPI.login();
512 const meshName = `${name} Devices`;
513 const meshResult = await meshAPI.createMesh(
514 meshName,
515 `Device group for ${name} (tenant:${newTenantId})`
516 );
517
518 if (meshResult && meshResult.meshId) {
519 // Update tenant with mesh group ID
520 await pool.query(
521 'UPDATE tenants SET meshcentral_group_id = $1, meshcentral_setup_complete = true WHERE tenant_id = $2',
522 [meshResult.meshId, newTenantId]
523 );
524 console.log(`✅ Created MeshCentral mesh group ${meshResult.meshId} for tenant ${name}`);
525 }
526 } catch (meshErr) {
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
529 }
530
531 // Log the action
532 await pool.query(
533 `INSERT INTO tenant_audit_log (user_id, tenant_id, action, resource_type, details)
534 VALUES ($1, $2, 'create', 'tenant', $3)`,
535 [
536 req.user.userId,
537 newTenantId,
538 JSON.stringify({ name, subdomain })
539 ]
540 );
541
542 // Create subdomain in Cloudflare
543 const target = process.env.BASE_APP_DOMAIN || 'app.everydayoffice.au';
544 let dnsRecord = null;
545 let dnsError = null;
546 // DNS provisioning with retry
547 for (let attempt = 1; attempt <= 2; attempt++) {
548 try {
549 dnsRecord = await createSubdomain(finalSubdomain, target);
550 console.log(`Tenant domain ready: ${dnsRecord}`);
551 break;
552 } catch (cfErr) {
553 dnsError = cfErr;
554 console.error(`⚠️ Failed to provision DNS (attempt ${attempt}):`, cfErr.message);
555 if (attempt === 2) {
556 // Optionally: notify admin or mark tenant for DNS retry
557 }
558 }
559 }
560
561 // Fetch the updated tenant record with mesh group ID
562 const updatedTenant = await pool.query(
563 'SELECT * FROM tenants WHERE tenant_id = $1',
564 [newTenantId]
565 );
566
567 res.status(201).json({
568 tenant: updatedTenant.rows[0],
569 subdomain_url: dnsRecord ? `https://${dnsRecord}` : null
570 });
571 } catch (err) {
572 console.error('Error creating tenant:', err);
573
574 if (err.code === '23505') { // Unique violation
575 return res.status(409).json({ error: 'Subdomain already exists' });
576 }
577
578 res.status(500).json({ error: 'Failed to create tenant' });
579 }
580});
581
582/**
583 * @api {put} /tenants/:id Update Tenant
584 * @apiName UpdateTenant
585 * @apiGroup Tenants
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.
589 *
590 * **Updateable Fields:**
591 * - name: Tenant/company name (shown in UI, reports)
592 * - status: Operational status (active, suspended, inactive)
593 *
594 * **Status Values:**
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
598 *
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
604 *
605 * **Audit Logging:**
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):
622 * curl -X PUT \
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):
628 * curl -X PUT \
629 * -H "Authorization: Bearer eyJhbGc..." \
630 * -H "Content-Type: application/json" \
631 * -d '{
632 * "name": "Acme Corporation (EMEA)",
633 * "status": "active"
634 * }' \
635 * "https://api.everydaytech.au/tenants/5"
636 *
637 * @apiExample {json} Success Response:
638 * HTTP/1.1 200 OK
639 * {
640 * "tenant_id": 5,
641 * "name": "Acme Corporation (EMEA)",
642 * "subdomain": "acmecorp",
643 * "status": "active",
644 * "is_msp": false,
645 * "updated_at": "2024-03-11T17:00:00.000Z"
646 * }
647 *
648 * @since 1.0.0
649 * @see {@link module:routes/tenants.createTenant} for creating tenants
650 * @see {@link module:routes/tenants.updateMSPStatus} for changing MSP status
651 */
652router.put('/:id', requireRoot, async (req, res) => {
653 const { id } = req.params;
654 const { name, status } = req.body;
655
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(', ')}`
661 });
662 }
663
664 try {
665 const result = await pool.query(
666 `UPDATE tenants
667 SET name = COALESCE($1, name),
668 status = COALESCE($2, status),
669 updated_at = NOW()
670 WHERE tenant_id = $3
671 RETURNING *`,
672 [name, status, id]
673 );
674
675 if (result.rows.length === 0) {
676 return res.status(404).json({ error: 'Tenant not found' });
677 }
678
679 // Log the action
680 await pool.query(
681 `INSERT INTO tenant_audit_log (user_id, tenant_id, action, resource_type, details)
682 VALUES ($1, $2, 'update', 'tenant', $3)`,
683 [
684 req.user.userId,
685 id,
686 JSON.stringify({ name, status })
687 ]
688 );
689
690 res.json(result.rows[0]);
691 } catch (err) {
692 console.error('Error updating tenant:', err);
693 res.status(500).json({ error: 'Failed to update tenant' });
694 }
695});
696
697/**
698 * @api {get} /tenants/:id/audit Get Tenant Audit Log
699 * @apiName GetTenantAudit
700 * @apiGroup Tenants
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.
704 *
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
711 *
712 * **Audit Fields:**
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
719 *
720 * **Pagination:**
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
742 *
743 * @apiExample {curl} Example Request:
744 * curl -X GET \
745 * -H "Authorization: Bearer eyJhbGc..." \
746 * "https://api.everydaytech.au/tenants/5/audit?limit=50&page=1"
747 *
748 * @apiExample {json} Success Response:
749 * HTTP/1.1 200 OK
750 * {
751 * "logs": [
752 * {
753 * "id": 142,
754 * "user_id": 1,
755 * "tenant_id": 5,
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"
763 * },
764 * {
765 * "id": 89,
766 * "user_id": 1,
767 * "tenant_id": 5,
768 * "action": "create",
769 * "resource_type": "user",
770 * "resource_id": 23,
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"
775 * }
776 * ],
777 * "total": 47
778 * }
779 *
780 * @since 1.0.0
781 * @see {@link module:routes/tenants.getImpersonationAudit} for impersonation audit trail
782 */
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;
788
789 try {
790 const result = await pool.query(
791 `SELECT
792 a.*,
793 u.name as user_name,
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
799 LIMIT $2 OFFSET $3`,
800 [id, limit, offset]
801 );
802
803 const countResult = await pool.query(
804 'SELECT COUNT(*) FROM tenant_audit_log WHERE tenant_id = $1',
805 [id]
806 );
807
808 res.json({
809 logs: result.rows,
810 total: parseInt(countResult.rows[0].count)
811 });
812 } catch (err) {
813 console.error('Error fetching audit log:', err);
814 res.status(500).json({ error: 'Failed to fetch audit log' });
815 }
816});
817
818/**
819 * @api {post} /tenants/:id/users Create Tenant User
820 * @apiName CreateTenantUser
821 * @apiGroup Tenants
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.
825 *
826 * **User Roles:**
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)
831 *
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
836 *
837 * **Validation:**
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
842 *
843 * **Audit Logging:**
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
864 *
865 * @apiExample {curl} Example Request:
866 * curl -X POST \
867 * -H "Authorization: Bearer eyJhbGc..." \
868 * -H "Content-Type: application/json" \
869 * -d '{
870 * "username": "jdoe",
871 * "email": "jdoe@acme.com",
872 * "password": "SecurePass123!",
873 * "role": "admin"
874 * }' \
875 * "https://api.everydaytech.au/tenants/5/users"
876 *
877 * @apiExample {json} Success Response:
878 * HTTP/1.1 201 Created
879 * {
880 * "user_id": 23,
881 * "tenant_id": 5,
882 * "username": "jdoe",
883 * "email": "jdoe@acme.com",
884 * "role": "admin",
885 * "created_at": "2024-03-11T17:15:00.000Z"
886 * }
887 *
888 * @since 1.0.0
889 * @see {@link module:routes/users} for user management API
890 * @see {@link module:routes/tenants.getTenant} for tenant details with user count
891 */
892router.post('/:id/users', requireRoot, async (req, res) => {
893 const { id } = req.params;
894 const { username, email, password, role } = req.body;
895
896 if (!username || !email || !password) {
897 return res.status(400).json({ error: 'Username, email, and password are required' });
898 }
899
900 const allowedRoles = ['owner', 'admin', 'agent', 'read'];
901 const userRole = role || 'agent';
902
903 if (!allowedRoles.includes(userRole)) {
904 return res.status(400).json({
905 error: `Role must be one of: ${allowedRoles.join(', ')}`
906 });
907 }
908
909 try {
910 // Verify tenant exists
911 const tenantCheck = await pool.query(
912 'SELECT tenant_id FROM tenants WHERE tenant_id = $1',
913 [id]
914 );
915
916 if (tenantCheck.rows.length === 0) {
917 return res.status(404).json({ error: 'Tenant not found' });
918 }
919
920 const bcrypt = require('bcrypt');
921 const hashedPassword = await bcrypt.hash(password, 10);
922
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]
928 );
929
930 // Log the action
931 await pool.query(
932 `INSERT INTO tenant_audit_log (user_id, tenant_id, action, resource_type, resource_id, details)
933 VALUES ($1, $2, 'create', 'user', $3, $4)`,
934 [
935 req.user.userId,
936 id,
937 result.rows[0].user_id,
938 JSON.stringify({ username, email, role: userRole })
939 ]
940 );
941
942 res.status(201).json(result.rows[0]);
943 } catch (err) {
944 console.error('Error creating user:', err);
945
946 if (err.code === '23505') { // Unique violation
947 return res.status(409).json({ error: 'Username or email already exists' });
948 }
949
950 res.status(500).json({ error: 'Failed to create user' });
951 }
952});
953
954/**
955 * @api {put} /tenants/:id/msp Update MSP Status
956 * @apiName UpdateMSPStatus
957 * @apiGroup Tenants
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.
961 *
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
968 *
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.
972 *
973 * **Audit Logging:**
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):
987 * curl -X PUT \
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):
993 * curl -X PUT \
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:
999 * HTTP/1.1 200 OK
1000 * {
1001 * "tenant_id": 5,
1002 * "name": "Acme Corporation",
1003 * "subdomain": "acmecorp",
1004 * "is_msp": true,
1005 * "updated_at": "2024-03-11T17:30:00.000Z"
1006 * }
1007 * @since 1.0.0
1008 * @see {@link module:routes/auth.impersonate} for MSP tenant impersonation
1009 * @see {@link module:routes/tenants.updateTenant} for general tenant updates
1010 */
1011router.put('/:id/msp', requireRoot, async (req, res) => {
1012 const { id } = req.params;
1013 const { is_msp } = req.body;
1014
1015 if (typeof is_msp !== 'boolean') {
1016 return res.status(400).json({ error: 'is_msp must be a boolean value' });
1017 }
1018
1019 try {
1020 const result = await pool.query(
1021 `UPDATE tenants
1022 SET is_msp = $1, updated_at = NOW()
1023 WHERE tenant_id = $2
1024 RETURNING *`,
1025 [is_msp, id]
1026 );
1027
1028 if (result.rows.length === 0) {
1029 return res.status(404).json({ error: 'Tenant not found' });
1030 }
1031
1032 // Log the action
1033 await pool.query(
1034 `INSERT INTO tenant_audit_log (user_id, tenant_id, action, resource_type, details)
1035 VALUES ($1, $2, 'update_msp_status', 'tenant', $3)`,
1036 [
1037 req.user.userId,
1038 id,
1039 JSON.stringify({ is_msp })
1040 ]
1041 );
1042
1043 res.json(result.rows[0]);
1044 } catch (err) {
1045 console.error('Error updating MSP status:', err);
1046 res.status(500).json({ error: 'Failed to update MSP status' });
1047 }
1048});
1049
1050/**
1051 * @api {delete} /tenants/:id Delete Tenant
1052 * @apiName DeleteTenant
1053 * @apiGroup Tenants
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.
1057 *
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)
1063 *
1064 * **Related Data Check:**
1065 * System counts records in:
1066 * - users, customers, tickets, invoices, contracts
1067 * - agents (RMM), domains, purchase_orders
1068 *
1069 * If ANY count > 0 and force != 'true', deletion blocked with detailed error showing counts.
1070 *
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)
1080 * 9. Tenant record
1081 *
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):
1096 * curl -X DELETE \
1097 * -H "Authorization: Bearer eyJhbGc..." \
1098 * "https://api.everydaytech.au/tenants/5"
1099 * @apiExample {curl} Example Request (Force Delete):
1100 * curl -X DELETE \
1101 * -H "Authorization: Bearer eyJhbGc..." \
1102 * "https://api.everydaytech.au/tenants/5?force=true"
1103 * @apiExample {json} Success Response:
1104 * HTTP/1.1 200 OK
1105 * {
1106 * "message": "Tenant deleted successfully",
1107 * "tenant": {
1108 * "tenant_id": 5,
1109 * "name": "Acme Corporation",
1110 * "subdomain": "acmecorp"
1111 * },
1112 * "cascade_deleted": true
1113 * }
1114 * @apiExample {json} Error Response (Has Data):
1115 * HTTP/1.1 400 Bad Request
1116 * {
1117 * "error": "Cannot delete tenant with related data",
1118 * "details": {
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"
1127 * },
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."
1129 * }
1130 * @apiExample {json} Error Response (MSP Tenant):
1131 * HTTP/1.1 400 Bad Request
1132 * {
1133 * "error": "Cannot delete root/MSP tenant",
1134 * "message": "The MSP tenant cannot be deleted as it manages the system"
1135 * }
1136 *
1137 * @since 1.0.0
1138 * @see {@link module:routes/tenants.updateTenant} for suspending tenants (soft delete alternative)
1139 */
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
1143
1144 const client = await pool.connect();
1145 try {
1146 await client.query('BEGIN');
1147
1148 // Check if tenant exists
1149 const checkQuery = await client.query(
1150 'SELECT is_msp, name FROM tenants WHERE tenant_id = $1',
1151 [id]
1152 );
1153
1154 if (checkQuery.rows.length === 0) {
1155 await client.query('ROLLBACK');
1156 return res.status(404).json({ error: 'Tenant not found' });
1157 }
1158
1159 const tenant = checkQuery.rows[0];
1160
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'
1167 });
1168 }
1169
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]);
1173
1174 // Check for related data
1175 const relatedCheck = await client.query(`
1176 SELECT
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
1185 `, [id]);
1186
1187 const counts = relatedCheck.rows[0];
1188 const hasRelatedData = Object.values(counts).some(count => parseInt(count) > 0);
1189
1190 if (hasRelatedData && force !== 'true') {
1191 await client.query('ROLLBACK');
1192 return res.status(400).json({
1193 error: 'Cannot delete tenant with related data',
1194 details: counts,
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.'
1196 });
1197 }
1198
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...`);
1202
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]);
1208
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]);
1212
1213 // Purchase orders
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]);
1216
1217 // Contracts
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]);
1220
1221 // Domains
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]);
1225
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]);
1229
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]);
1234
1235 // Users last
1236 await client.query('DELETE FROM users WHERE tenant_id = $1', [id]);
1237
1238 console.log(`[Tenant Delete] Deleted all related data for tenant ${id}`);
1239 }
1240
1241 // Delete tenant
1242 const result = await client.query(
1243 'DELETE FROM tenants WHERE tenant_id = $1 RETURNING *',
1244 [id]
1245 );
1246
1247 await client.query('COMMIT');
1248
1249 res.json({
1250 message: 'Tenant deleted successfully',
1251 tenant: result.rows[0],
1252 cascade_deleted: force === 'true'
1253 });
1254 } catch (err) {
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
1261 });
1262 } finally {
1263 client.release();
1264 }
1265});
1266
1267/**
1268 * @api {get} /tenants/:id/impersonation-audit Get Impersonation Audit Log
1269 * @apiName GetImpersonationAudit
1270 * @apiGroup Tenants
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.
1274 *
1275 * **Future Implementation:**
1276 * Will log:
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)
1283 *
1284 * **Use Cases:**
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)
1289 *
1290 * **Pagination:**
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:
1301 * curl -X GET \
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):
1305 * HTTP/1.1 200 OK
1306 * {
1307 * "logs": [],
1308 * "message": "Impersonation audit log not yet implemented"
1309 * }
1310 * @apiExample {json} Future Response (When Implemented):
1311 * HTTP/1.1 200 OK
1312 * {
1313 * "logs": [
1314 * {
1315 * "id": 42,
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
1324 * }
1325 * ]
1326 * }
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
1330 */
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;
1335
1336 try {
1337 // TODO: Implement when impersonation_audit table is created
1338 res.json({ logs: [], message: 'Impersonation audit log not yet implemented' });
1339
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]
1346 // );
1347 // res.json({ logs: result.rows });
1348 } catch (err) {
1349 console.error('Error fetching impersonation audit log:', err);
1350 res.status(500).json({ error: 'Failed to fetch impersonation audit log' });
1351 }
1352});
1353
1354module.exports = router;