2 * @file customerMerge.js
3 * @module routes/customerMerge
4 * @description Customer merge endpoints for consolidating duplicate customer records.
5 * Provides comparison and merge operations with related data migration (tickets, invoices,
6 * contracts, agents, hosting resources). Admin-only operations.
8 * @requires middleware/auth
9 * @requires middleware/adminOnly
10 * @requires middleware/tenant
12 * @author RMM-PSA Development Team
13 * @copyright 2026 RMM-PSA Platform
14 * @license Proprietary
18 * @apiDefine CustomerMerge Customer Merge
19 * Duplicate customer consolidation operations (admin only)
22const express = require('express');
23const router = express.Router();
24const authenticateToken = require('../middleware/auth');
25const requireAdmin = require('../middleware/adminOnly');
26const { setTenantContext } = require('../middleware/tenant');
27const pool = require('../db');
29router.use(authenticateToken, setTenantContext);
32 * @api {get} /api/customers/merge/compare/:id1/:id2 Compare customers
33 * @apiName CompareCustomersForMerge
34 * @apiGroup CustomerMerge
35 * @apiDescription Compare two customer records to review data before merge.
36 * Returns both customer details plus counts of related records (tickets, invoices,
37 * contracts, agents, hosting resources). Enforces tenant isolation.
38 * @apiParam {number} id1 First customer ID
39 * @apiParam {number} id2 Second customer ID
40 * @apiSuccess {object} customer1 First customer details and counts
41 * @apiSuccess {number} customer1.customer_id Customer ID
42 * @apiSuccess {string} customer1.name Customer name
43 * @apiSuccess {string} customer1.email Customer email
44 * @apiSuccess {number} customer1.tickets_count Number of tickets
45 * @apiSuccess {number} customer1.invoices_count Number of invoices
46 * @apiSuccess {number} customer1.contracts_count Number of contracts
47 * @apiSuccess {number} customer1.agents_count Number of agents
48 * @apiSuccess {number} customer1.hosting_apps_count Number of hosting apps
49 * @apiSuccess {Object} customer2 Second customer details and counts (same structure)
50 * @apiError (404) {String} error="One or both customers not found" Customer ID(s) not found or tenant mismatch
51 * @apiError (500) {String} error="Failed to compare customers" Database query failed
52 * @apiExample {curl} Example:
53 * curl -X GET http://localhost:3000/api/customers/merge/compare/42/43 \\
54 * -H "Authorization: Bearer YOUR_TOKEN"
55 * @apiSuccessExample {json} Success-Response:
60 * "name": "Acme Corp",
61 * "email": "contact@acme.com",
62 * "tickets_count": 15,
63 * "invoices_count": 8,
64 * "contracts_count": 2,
66 * "hosting_apps_count": 3
70 * "name": "Acme Corporation",
71 * "email": "admin@acme.com",
73 * "invoices_count": 1,
74 * "contracts_count": 0,
76 * "hosting_apps_count": 0
80router.get('/merge/compare/:id1/:id2', async (req, res) => {
81 const { id1, id2 } = req.params;
84 // Get customer details
85 const [customer1, customer2] = await Promise.all([
86 pool.query('SELECT * FROM customers WHERE customer_id = $1 AND tenant_id = $2', [id1, req.tenant.id]),
87 pool.query('SELECT * FROM customers WHERE customer_id = $1 AND tenant_id = $2', [id2, req.tenant.id])
90 if (customer1.rows.length === 0 || customer2.rows.length === 0) {
91 return res.status(404).json({ error: 'One or both customers not found' });
94 // Get counts of related data for both customers
98 contracts1, contracts2,
100 hostingApps1, hostingApps2
101 ] = await Promise.all([
102 pool.query('SELECT COUNT(*) FROM tickets WHERE customer_id = $1', [id1]),
103 pool.query('SELECT COUNT(*) FROM tickets WHERE customer_id = $1', [id2]),
104 pool.query('SELECT COUNT(*) FROM invoices WHERE customer_id = $1', [id1]),
105 pool.query('SELECT COUNT(*) FROM invoices WHERE customer_id = $1', [id2]),
106 pool.query('SELECT COUNT(*) FROM contracts WHERE customer_id = $1', [id1]),
107 pool.query('SELECT COUNT(*) FROM contracts WHERE customer_id = $1', [id2]),
108 pool.query('SELECT COUNT(*) FROM agents WHERE customer_id = $1', [id1]),
109 pool.query('SELECT COUNT(*) FROM agents WHERE customer_id = $1', [id2]),
110 pool.query('SELECT COUNT(*) FROM hosting_apps WHERE customer_id = $1', [id1]),
111 pool.query('SELECT COUNT(*) FROM hosting_apps WHERE customer_id = $1', [id2])
116 ...customer1.rows[0],
117 tickets_count: parseInt(tickets1.rows[0].count),
118 invoices_count: parseInt(invoices1.rows[0].count),
119 contracts_count: parseInt(contracts1.rows[0].count),
120 agents_count: parseInt(agents1.rows[0].count),
121 hosting_apps_count: parseInt(hostingApps1.rows[0].count)
124 ...customer2.rows[0],
125 tickets_count: parseInt(tickets2.rows[0].count),
126 invoices_count: parseInt(invoices2.rows[0].count),
127 contracts_count: parseInt(contracts2.rows[0].count),
128 agents_count: parseInt(agents2.rows[0].count),
129 hosting_apps_count: parseInt(hostingApps2.rows[0].count)
133 console.error('[Customer Merge] Error comparing customers:', error);
134 res.status(500).json({ error: 'Failed to compare customers' });
139 * @api {post} /api/customers/merge Merge customers
140 * @apiName MergeCustomers
141 * @apiGroup CustomerMerge
142 * @apiDescription Merge two customer records - migrate all data from secondary to primary, then delete secondary.
143 * Admin only. Performs atomic transaction to migrate tickets, invoices, contracts, agents,
144 * and hosting resources (apps, droplets, databases). Requires explicit admin authentication.
145 * @apiParam {number} primary_customer_id Primary customer ID (data preserved)
146 * @apiParam {number} secondary_customer_id Secondary customer ID (will be deleted after merge)
147 * @apiSuccess {string} message="Customers merged successfully"
148 * @apiSuccess {number} primary_customer_id Primary customer ID
149 * @apiSuccess {number} secondary_customer_id Deleted secondary customer ID
150 * @apiError (400) {String} error="Both customer IDs are required" Missing customer IDs
151 * @apiError (400) {String} error="Cannot merge a customer with itself" Same ID provided for both
152 * @apiError (403) {String} error="Forbidden" User not admin
153 * @apiError (500) {String} error Error message from merge operation (transaction rolled back)
154 * @apiExample {curl} Example:
155 * curl -X POST http://localhost:3000/api/customers/merge \\
156 * -H "Authorization: Bearer YOUR_TOKEN" \\
157 * -H "Content-Type: application/json" \\
159 * "primary_customer_id": 42,
160 * "secondary_customer_id": 43
162 * @apiSuccessExample {json} Success-Response:
165 * "message": "Customers merged successfully",
166 * "primary_customer_id": 42,
167 * "secondary_customer_id": 43
170router.post('/merge', requireAdmin, async (req, res) => {
171 const { primary_customer_id, secondary_customer_id } = req.body;
173 if (!primary_customer_id || !secondary_customer_id) {
174 return res.status(400).json({ error: 'Both customer IDs are required' });
177 if (primary_customer_id === secondary_customer_id) {
178 return res.status(400).json({ error: 'Cannot merge a customer with itself' });
181 const client = await pool.connect();
184 await client.query('BEGIN');
186 // Verify both customers exist and belong to this tenant
187 const customersCheck = await client.query(
188 'SELECT customer_id FROM customers WHERE customer_id IN ($1, $2) AND tenant_id = $3',
189 [primary_customer_id, secondary_customer_id, req.tenant.id]
192 if (customersCheck.rows.length !== 2) {
193 throw new Error('One or both customers not found or do not belong to this tenant');
196 // Migrate all related data from secondary to primary
197 await client.query('UPDATE tickets SET customer_id = $1 WHERE customer_id = $2', [primary_customer_id, secondary_customer_id]);
198 await client.query('UPDATE invoices SET customer_id = $1 WHERE customer_id = $2', [primary_customer_id, secondary_customer_id]);
199 await client.query('UPDATE contracts SET customer_id = $1 WHERE customer_id = $2', [primary_customer_id, secondary_customer_id]);
200 await client.query('UPDATE agents SET customer_id = $1 WHERE customer_id = $2', [primary_customer_id, secondary_customer_id]);
201 await client.query('UPDATE hosting_apps SET customer_id = $1 WHERE customer_id = $2', [primary_customer_id, secondary_customer_id]);
202 await client.query('UPDATE hosting_droplets SET customer_id = $1 WHERE customer_id = $2', [primary_customer_id, secondary_customer_id]);
203 await client.query('UPDATE hosting_databases SET customer_id = $1 WHERE customer_id = $2', [primary_customer_id, secondary_customer_id]);
205 // Delete the secondary customer
206 await client.query('DELETE FROM customers WHERE customer_id = $1', [secondary_customer_id]);
208 await client.query('COMMIT');
211 message: 'Customers merged successfully',
213 secondary_customer_id
216 await client.query('ROLLBACK');
217 console.error('[Customer Merge] Error merging customers:', error);
218 res.status(500).json({ error: error.message || 'Failed to merge customers' });
224module.exports = router;