EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
customers.js
Go to the documentation of this file.
1/**
2 * @file customers.js
3 * @brief Customer Management API Routes
4 * @description Comprehensive customer relationship management (CRM) system for MSP/PSA
5 * operations. Manages customer records, contacts, and relationships to tickets, invoices,
6 * contracts, domains, Office 365 subscriptions, and RMM agents. Multi-tenant isolated with
7 * full CRUD operations and hierarchical contact management.
8 *
9 * **Core Features:**
10 * - Customer CRUD operations (create, read, update, delete)
11 * - Multi-tenant isolation (MSP and sub-tenant level)
12 * - Customer contact management (primary/secondary contacts)
13 * - Status tracking (active/inactive)
14 * - Advanced search and filtering
15 * - Comprehensive relationship data (tickets, invoices, contracts, etc.)
16 * - Redis event publishing for integrations
17 * - Safe deletion with referential integrity checks
18 *
19 * **Related Entities:**
20 * - Tickets: Support tickets for customer
21 * - Invoices: Billing records
22 * - Contracts: Service agreements
23 * - Agents: RMM agents deployed at customer sites
24 * - Domains: Registered domain names
25 * - Office 365 Subscriptions: Microsoft 365 licenses
26 * - Hosting Apps: Deployed web applications
27 *
28 * **Database Schema:**
29 * - customers: Main customer records
30 * - customer_contacts: Contact persons (with primary flag)
31 * - Foreign keys to: tickets, invoices, contracts, domains, etc.
32 * @module routes/customers
33 * @requires express
34 * @requires services/db
35 * @requires middleware/auth
36 * @requires middleware/adminOnly
37 * @requires middleware/tenant
38 * @requires utils/redis
39 * @author RMM-PSA Platform
40 * @date 2026-02-10
41 * @since 2.0.0
42 */
43const express = require('express');
44const router = express.Router();
45const pool = require('../services/db');
46const authenticateToken = require('../middleware/auth');
47const requireAdmin = require('../middleware/adminOnly');
48const { getTenantFilter, setTenantContext } = require('../middleware/tenant');
49
50// Apply authentication and tenant context to all routes
51router.use(authenticateToken, setTenantContext);
52
53/**
54 * @api {get} /customers List all customers
55 * @apiName ListCustomers
56 * @apiGroup Customers
57 * @apiDescription Retrieves paginated list of customers with advanced filtering capabilities.
58 * Supports search by name, status filtering, and date range queries. Results include
59 * tenant name for multi-tenant visibility. Tenant-isolated automatically.
60 *
61 * **Query Parameters:**
62 * - page (default: 1): Page number for pagination
63 * - limit (default: 10): Results per page
64 * - search: Search in customer name (case-insensitive)
65 * - status: Filter by status (active, inactive, or omit for all)
66 * - date_from: Filter customers created after this date (ISO 8601)
67 * - date_to: Filter customers created before this date (end of day included)
68 *
69 * **Performance:**
70 * - Indexed queries for fast pagination
71 * - LEFT JOIN for tenant name lookup
72 * - Separate COUNT query for total
73 * @apiHeader {string} Authorization Bearer JWT token with tenant context
74 * @apiParam {number} [page=1] Page number
75 * @apiParam {number} [limit=10] Results per page
76 * @apiParam {string} [search] Search query (customer name)
77 * @apiParam {string} [status] Status filter (active or inactive)
78 * @apiParam {string} [date_from] Start date (ISO 8601)
79 * @apiParam {string} [date_to] End date (ISO 8601)
80 * @apiSuccess {Array} customers Array of customer objects with tenant names
81 * @apiSuccess {number} total Total count of matching customers
82 * @apiExample {curl} List Active Customers:
83 * curl -H "Authorization: Bearer eyJhbGc..." \\
84 * "https://api.everydaytech.au/customers?status=active&page=1&limit=20"
85 * @apiExample {curl} Search Customers:
86 * curl -H "Authorization: Bearer eyJhbGc..." \\
87 * "https://api.everydaytech.au/customers?search=acme"
88 * @apiExample {json} Success Response:
89 * HTTP/1.1 200 OK
90 * {
91 * "customers": [
92 * {
93 * "customer_id": 42,
94 * "name": "Acme Corporation",
95 * "contact_name": "John Smith",
96 * "email": "john@example.com",
97 * "phone": "+1-555-0123",
98 * "status": "active",
99 * "tenant_name": "Main MSP",
100 * "created_at": "2025-08-15T10:30:00Z"
101 * }
102 * ],
103 * "total": 156
104 * }
105 * @since 2.0.0
106 * @see {@link module:routes/customers.getCustomer} for single customer details
107 */
108router.get('/', async (req, res) => {
109 const page = parseInt(req.query.page, 10) || 1;
110 const limit = parseInt(req.query.limit, 10) || 10;
111 const offset = (page - 1) * limit;
112 const search = req.query.search;
113 const status = req.query.status; // 'active' | 'inactive'
114 const dateFrom = req.query.date_from; // created_at >= date
115 const dateTo = req.query.date_to; // created_at <= date end of day
116
117 try {
118 let query = 'SELECT c.*, t.name AS tenant_name FROM customers c LEFT JOIN tenants t ON c.tenant_id = t.tenant_id';
119 let countQuery = 'SELECT COUNT(*) FROM customers c';
120 const where = [];
121 const params = [];
122
123 // Tenant filter
124 const { clause: tenantClause, params: tenantParams, nextParamIndex } = getTenantFilter(req, 'c');
125 if (tenantClause) {
126 where.push(tenantClause);
127 params.push(...tenantParams);
128 }
129 let paramIndex = nextParamIndex;
130
131 // Search
132 if (search) {
133 params.push(`%${search}%`);
134 where.push(`(c.name ILIKE $${paramIndex} OR c.contact_name ILIKE $${paramIndex} OR c.email ILIKE $${paramIndex} OR c.phone ILIKE $${paramIndex} OR c.city ILIKE $${paramIndex})`);
135 paramIndex++;
136 }
137
138 // Status filter
139 if (status && (status === 'active' || status === 'inactive')) {
140 params.push(status);
141 where.push(`c.status = $${paramIndex}`);
142 paramIndex++;
143 }
144
145 // Date range filter (created_at)
146 if (dateFrom) {
147 params.push(dateFrom);
148 where.push(`c.created_at >= $${paramIndex}`);
149 paramIndex++;
150 }
151 if (dateTo) {
152 // include entire day for date_to by adding 1 day and using < next day
153 params.push(dateTo);
154 where.push(`c.created_at < ($${paramIndex}::date + INTERVAL '1 day')`);
155 paramIndex++;
156 }
157
158 if (where.length) {
159 const clause = ' WHERE ' + where.join(' AND ');
160 query += clause;
161 countQuery += clause;
162 }
163
164 params.push(limit, offset);
165 query += ` ORDER BY c.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
166
167 const [totalRes, result] = await Promise.all([
168 pool.query(countQuery, params.slice(0, params.length - 2)),
169 pool.query(query, params)
170 ]);
171
172 res.json({ customers: result.rows, total: parseInt(totalRes.rows[0].count, 10) });
173 } catch (err) {
174 console.error('Error fetching customers:', err);
175 res.status(500).json({ error: 'Server error', details: err.message });
176 }
177});
178
179/**
180 * @api {get} /customers/:id Get customer details
181 * @apiName GetCustomer
182 * @apiGroup Customers
183 * @apiDescription Retrieves complete customer information including all related entities.
184 * Returns customer details along with tickets, invoices, contracts, agents, contacts,
185 * domains, Office 365 subscriptions, and hosting applications. Provides comprehensive
186 * 360-degree view of customer relationship. Gracefully handles missing related tables.
187 *
188 * **Included Related Data:**
189 * - **Tickets:** All support tickets for customer (ordered by created_at DESC)
190 * - **Invoices:** All billing invoices (ordered by issued_date DESC)
191 * - **Agents:** RMM agents deployed at customer sites
192 * - **Contacts:** Customer contact persons (ordered by is_primary DESC)
193 * - **Contracts:** Service agreements and SLAs (ordered by created_at DESC)
194 * - **Domains:** Registered domains associated with customer
195 * - **Office365 Subscriptions:** Microsoft 365 licenses and subscriptions
196 * - **Hosting Apps:** Deployed web applications/websites
197 *
198 * **Multi-Tenant Isolation:**
199 * All related queries respect tenant boundaries - only returns data accessible
200 * to the authenticated tenant.
201 *
202 * **Error Handling:**
203 * If any related table doesn't exist or query fails, returns empty array for
204 * that relation rather than failing entire request.
205 * @apiHeader {string} Authorization Bearer JWT token with tenant context
206 * @apiParam {number} id Customer ID (URL parameter)
207 * @apiSuccess {object} customer Complete customer object with tenant_name
208 * @apiSuccess {Array} tickets Related tickets
209 * @apiSuccess {Array} invoices Related invoices
210 * @apiSuccess {Array} agents Related RMM agents
211 * @apiSuccess {Array} contacts Customer contacts
212 * @apiSuccess {Array} contracts Service contracts
213 * @apiSuccess {Array} domains Registered domains
214 * @apiSuccess {Array} office365_subscriptions Microsoft 365 subscriptions
215 * @apiSuccess {Array} hosting_apps Hosted web applications
216 * @apiError {number} 404 Customer not found or access denied
217 * @apiError {number} 500 Server error
218 * @apiExample {curl} Example Request:
219 * curl -H "Authorization: Bearer eyJhbGc..." \\
220 * "https://api.everydaytech.au/customers/42"
221 * @apiExample {json} Success Response:
222 * HTTP/1.1 200 OK
223 * {
224 * "customer": {
225 * "customer_id": 42,
226 * "name": "Acme Corporation",
227 * "email": "john@acme.com",
228 * "phone": "+1-555-0123",
229 * "status": "active",
230 * "tenant_name": "Main MSP",
231 * "created_at": "2025-08-15T10:30:00Z"
232 * },
233 * "tickets": [
234 * { "ticket_id": 125, "title": "Email issues", "status": "open" }
235 * ],
236 * "invoices": [
237 * { "invoice_id": 890, "amount": 299.99, "status": "paid" }
238 * ],
239 * "agents": [
240 * { "agent_id": "abc123", "name": "DC01", "status": "online" }
241 * ],
242 * "contacts": [
243 * { "contact_id": 1, "name": "John Smith", "is_primary": true }
244 * ],
245 * "contracts": [
246 * { "contract_id": 5, "name": "Managed Services", "status": "active" }
247 * ],
248 * "domains": [
249 * { "domain_id": 78, "domain_name": "acme.com", "expires_on": "2027-01-15" }
250 * ],
251 * "office365_subscriptions": [],
252 * "hosting_apps": []
253 * }
254 * @since 2.0.0
255 * @see {@link module:routes/customers.updateCustomer} for updating customer
256 * @see {@link module:routes/tickets.listTickets} for ticket details
257 * @see {@link module:routes/contracts.listContracts} for contract details
258 */
259router.get('/:id', async (req, res) => {
260 const id = req.params.id;
261 try {
262 // Build query with tenant filtering
263 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
264 let query = 'SELECT c.*, t.name AS tenant_name FROM customers c LEFT JOIN tenants t ON c.tenant_id = t.tenant_id WHERE c.customer_id = $1';
265 const params = [id];
266
267 if (tenantClause) {
268 // Adjust tenant clause parameter indexes to start at $2 since $1 is customer_id
269 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (match, num) => `$${parseInt(num, 10) + 1}`);
270 query += ` AND ${adjustedClause}`;
271 params.push(...tenantParams);
272 }
273
274 const custRes = await pool.query(query, params);
275 if (custRes.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
276
277 const customer = custRes.rows[0];
278
279 // Fetch related data with tenant filtering - handle missing tables gracefully
280 const ticketFilter = getTenantFilter(req, 'tk');
281 const invoiceFilter = getTenantFilter(req, 'inv');
282 const agentFilter = getTenantFilter(req, 'ag');
283 const contractFilter = getTenantFilter(req, 'con');
284
285 const fetchRelatedData = async () => {
286 const results = {
287 tickets: [],
288 invoices: [],
289 agents: [],
290 contacts: [],
291 contracts: [],
292 domains: [],
293 office365_subscriptions: [],
294 hosting_apps: []
295 };
296
297 try {
298 let ticketQuery = 'SELECT tk.* FROM tickets tk WHERE tk.customer_id = $1';
299 const ticketParams = [id];
300 if (ticketFilter.clause) {
301 const adjustedClause = ticketFilter.clause.replace(/\$(\d+)/g, (match, num) => {
302 return `$${parseInt(num) + 1}`;
303 });
304 ticketQuery += ` AND ${adjustedClause}`;
305 ticketParams.push(...ticketFilter.params);
306 }
307 ticketQuery += ' ORDER BY tk.created_at DESC';
308 const ticketsRes = await pool.query(ticketQuery, ticketParams);
309 results.tickets = ticketsRes.rows;
310 } catch (err) {
311 console.warn('Error fetching tickets:', err.message);
312 }
313
314 try {
315 let invoiceQuery = 'SELECT inv.* FROM invoices inv WHERE inv.customer_id = $1';
316 const invoiceParams = [id];
317 if (invoiceFilter.clause) {
318 const adjustedClause = invoiceFilter.clause.replace(/\$(\d+)/g, (match, num) => {
319 return `$${parseInt(num) + 1}`;
320 });
321 invoiceQuery += ` AND ${adjustedClause}`;
322 invoiceParams.push(...invoiceFilter.params);
323 }
324 invoiceQuery += ' ORDER BY inv.issued_date DESC';
325 const invoicesRes = await pool.query(invoiceQuery, invoiceParams);
326 results.invoices = invoicesRes.rows;
327 } catch (err) {
328 console.warn('Error fetching invoices:', err.message);
329 }
330
331 try {
332 let agentQuery = 'SELECT ag.* FROM agents ag WHERE ag.customer_id = $1';
333 const agentParams = [id];
334 if (agentFilter.clause) {
335 const adjustedClause = agentFilter.clause.replace(/\$(\d+)/g, (match, num) => {
336 return `$${parseInt(num) + 1}`;
337 });
338 agentQuery += ` AND ${adjustedClause}`;
339 agentParams.push(...agentFilter.params);
340 }
341 agentQuery += ' ORDER BY ag.name';
342 const agentsRes = await pool.query(agentQuery, agentParams);
343 results.agents = agentsRes.rows;
344 } catch (err) {
345 console.warn('Error fetching agents:', err.message);
346 }
347
348 try {
349 const contactsRes = await pool.query('SELECT * FROM customer_contacts WHERE customer_id = $1 ORDER BY is_primary DESC, name ASC', [id]);
350 results.contacts = contactsRes.rows;
351 } catch (err) {
352 console.warn('Error fetching contacts:', err.message);
353 }
354
355 try {
356 let contractQuery = 'SELECT con.* FROM contracts con WHERE con.customer_id = $1';
357 const contractParams = [id];
358 if (contractFilter.clause) {
359 const adjustedClause = contractFilter.clause.replace(/\$(\d+)/g, (match, num) => {
360 return `$${parseInt(num) + 1}`;
361 });
362 contractQuery += ` AND ${adjustedClause}`;
363 contractParams.push(...contractFilter.params);
364 }
365 contractQuery += ' ORDER BY con.created_at DESC';
366 const contractsRes = await pool.query(contractQuery, contractParams);
367 results.contracts = contractsRes.rows;
368 } catch (err) {
369 console.warn('Error fetching contracts:', err.message);
370 }
371
372 // Fetch domains
373 try {
374 const domainsFilter = getTenantFilter(req, 'd');
375 let domainQuery = 'SELECT d.* FROM domains d WHERE d.customer_id = $1';
376 const domainParams = [id];
377 if (domainsFilter.clause) {
378 const adjustedClause = domainsFilter.clause.replace(/\$(\d+)/g, (match, num) => {
379 return `$${parseInt(num) + 1}`;
380 });
381 domainQuery += ` AND ${adjustedClause}`;
382 domainParams.push(...domainsFilter.params);
383 }
384 domainQuery += ' ORDER BY d.domain_name';
385 const domainsRes = await pool.query(domainQuery, domainParams);
386 results.domains = domainsRes.rows;
387 } catch (err) {
388 console.warn('Error fetching domains:', err.message);
389 }
390
391 // Fetch Office 365 subscriptions
392 try {
393 const o365Filter = getTenantFilter(req, 'o');
394 let o365Query = 'SELECT o.* FROM office365_subscriptions o WHERE o.customer_id = $1';
395 const o365Params = [id];
396 if (o365Filter.clause) {
397 const adjustedClause = o365Filter.clause.replace(/\$(\d+)/g, (match, num) => {
398 return `$${parseInt(num) + 1}`;
399 });
400 o365Query += ` AND ${adjustedClause}`;
401 o365Params.push(...o365Filter.params);
402 }
403 o365Query += ' ORDER BY o.created_at DESC';
404 const o365Res = await pool.query(o365Query, o365Params);
405 results.office365_subscriptions = o365Res.rows;
406 } catch (err) {
407 console.warn('Error fetching office365 subscriptions:', err.message);
408 }
409
410 // Fetch hosting apps
411 try {
412 const hostingFilter = getTenantFilter(req, 'h');
413 let hostingQuery = 'SELECT h.* FROM hosting_apps h WHERE h.customer_id = $1';
414 const hostingParams = [id];
415 if (hostingFilter.clause) {
416 const adjustedClause = hostingFilter.clause.replace(/\$(\d+)/g, (match, num) => {
417 return `$${parseInt(num) + 1}`;
418 });
419 hostingQuery += ` AND ${adjustedClause}`;
420 hostingParams.push(...hostingFilter.params);
421 }
422 hostingQuery += ' ORDER BY h.created_at DESC';
423 const hostingRes = await pool.query(hostingQuery, hostingParams);
424 results.hosting_apps = hostingRes.rows;
425 } catch (err) {
426 console.warn('Error fetching hosting apps:', err.message);
427 }
428
429 return results;
430 };
431
432 const relatedData = await fetchRelatedData();
433
434 res.json({
435 customer,
436 ...relatedData
437 });
438 } catch (err) {
439 console.error('Error fetching customer details:', err);
440 res.status(500).json({ error: 'Server error', details: err.message });
441 }
442});
443
444/**
445 * @api {post} /customers Create new customer
446 * @apiName CreateCustomer
447 * @apiGroup Customers
448 * @apiDescription Creates a new customer record in the system. Automatically sets tenant
449 * context from JWT, generates timestamps, and publishes customer.created event to Redis
450 * for downstream integrations (billing, CRM sync, reporting, etc.).
451 *
452 * **Default Values:**
453 * - status: 'active' (if not provided)
454 * - tenant_id: Extracted from JWT token automatically
455 * - created_at: Current timestamp
456 * - updated_at: Current timestamp
457 *
458 * **Event Publishing:**
459 * Publishes 'customer.created' event to Redis events channel with customer data for:
460 * - Billing system integration
461 * - CRM synchronization
462 * - Analytics and reporting
463 * - Webhook notifications
464 * @apiHeader {string} Authorization Bearer JWT token with tenant context
465 * @apiHeader {string} Content-Type application/json
466 * @apiParam {string} name Customer name/company name (required)
467 * @apiParam {string} [contact_name] Primary contact person name
468 * @apiParam {string} [email] Primary email address
469 * @apiParam {string} [phone] Primary phone number
470 * @apiParam {string} [address] Street address
471 * @apiParam {string} [city] City
472 * @apiParam {string} [state] State/province
473 * @apiParam {string} [postal_code] ZIP/postal code
474 * @apiParam {string} [country] Country
475 * @apiParam {string} [notes] Internal notes about customer
476 * @apiParam {string} [status=active] Customer status (active or inactive)
477 * @apiSuccess {Object} customer Complete newly created customer object
478 * @apiSuccess {number} customer.customer_id Auto-generated customer ID
479 * @apiError {number} 400 Missing required field (name)
480 * @apiError {number} 403 No tenant context (authentication issue)
481 * @apiError {number} 500 Failed to create customer
482 * @apiExample {curl} Example Request:
483 * curl -X POST \
484 * -H "Authorization: Bearer eyJhbGc..." \
485 * -H "Content-Type: application/json" \
486 * -d '{
487 * "name": "Acme Corporation",
488 * "contact_name": "John Smith",
489 * "email": "john@acme.com",
490 * "phone": "+1-555-0123",
491 * "address": "123 Business Blvd",
492 * "city": "Sydney",
493 * "state": "NSW",
494 * "postal_code": "2000",
495 * "country": "Australia",
496 * "status": "active"
497 * }' \
498 * "https://api.everydaytech.au/customers"
499 * @apiExample {json} Success Response:
500 * HTTP/1.1 201 Created
501 * {
502 * "customer_id": 42,
503 * "name": "Acme Corporation",
504 * "contact_name": "John Smith",
505 * "email": "john@acme.com",
506 * "phone": "+1-555-0123",
507 * "status": "active",
508 * "tenant_id": 1,
509 * "created_at": "2026-02-10T14:30:00Z",
510 * "updated_at": "2026-02-10T14:30:00Z"
511 * }
512 * @since 2.0.0
513 * @see {@link module:routes/customers.updateCustomer} for updating customers
514 * @see {@link module:utils/redis} for event publishing
515 */
516router.post('/', async (req, res) => {
517 const { name, contact_name = null, email = null, phone = null, address = null, city = null, state = null, postal_code = null, country = null, notes = null, status = 'active' } = req.body;
518
519 console.log('POST /customers - Request body:', req.body);
520 console.log('POST /customers - Tenant context:', req.tenant);
521
522 if (!name) {
523 return res.status(400).json({ error: 'Customer name is required' });
524 }
525
526 if (!req.tenant?.id) {
527 console.error('POST /customers - No tenant context found');
528 return res.status(403).json({ error: 'No tenant context' });
529 }
530
531 try {
532 console.log('POST /customers - Attempting to insert customer with tenant_id:', req.tenant.id);
533 const result = await pool.query(
534 `INSERT INTO customers (name, contact_name, email, phone, address, city, state, postal_code, country, notes, status, tenant_id)
535 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING *`,
536 [name, contact_name, email, phone, address, city, state, postal_code, country, notes, status, req.tenant.id]
537 );
538 const customer = result.rows[0];
539 console.log('POST /customers - Customer created successfully:', customer);
540
541 // Publish customer.created event to Redis
542 try {
543 const redis = require('../utils/redis');
544 const event = {
545 type: 'customer.created',
546 tenant_id: customer.tenant_id,
547 customer_id: customer.customer_id,
548 data: customer
549 };
550 redis.publish('events', JSON.stringify(event));
551 } catch (err) {
552 console.error('[Redis] Failed to publish customer.created event:', err);
553 }
554
555 res.status(201).json(customer);
556 } catch (err) {
557 console.error('Error creating customer:', err);
558 res.status(500).json({ error: 'Failed to create customer', details: err.message });
559 }
560});
561
562/**
563 * @api {put} /customers/:id Update customer
564 * @apiName UpdateCustomer
565 * @apiGroup Customers
566 * @apiDescription Updates an existing customer's details. Sets updated_at timestamp
567 * automatically. All fields should be provided in request body - include existing
568 * values for fields that should not change. Tenant isolation enforced automatically.
569 *
570 * **Updatable Fields:**
571 * - name: Customer/company name
572 * - contact_name: Primary contact person
573 * - email, phone: Contact information
574 * - address, city, state, postal_code, country: Physical address
575 * - notes: Internal notes
576 * - status: active or inactive
577 *
578 * **Status Changes:**
579 * Setting status to 'inactive' soft-disables customer without deleting data.
580 * Can be reactivated later by setting status back to 'active'.
581 * @apiHeader {string} Authorization Bearer JWT token with tenant context
582 * @apiHeader {string} Content-Type application/json
583 * @apiParam {number} id Customer ID (URL parameter)
584 * @apiParam {string} name Updated customer name
585 * @apiParam {string} contact_name Updated primary contact
586 * @apiParam {string} email Updated email
587 * @apiParam {string} phone Updated phone
588 * @apiParam {string} address Updated street address
589 * @apiParam {string} city Updated city
590 * @apiParam {string} state Updated state
591 * @apiParam {string} postal_code Updated postal code
592 * @apiParam {string} country Updated country
593 * @apiParam {string} notes Updated notes
594 * @apiParam {string} status Updated status (active or inactive)
595 * @apiSuccess {Object} customer Updated customer object with new updated_at timestamp
596 * @apiError {number} 404 Customer not found or access denied
597 * @apiError {number} 500 Failed to update customer
598 * @apiExample {curl} Example Request:
599 * curl -X PUT \
600 * -H "Authorization: Bearer eyJhbGc..." \
601 * -H "Content-Type: application/json" \
602 * -d '{
603 * "name": "Acme Corporation Pty Ltd",
604 * "contact_name": "Jane Smith",
605 * "email": "jane@acme.com",
606 * "phone": "+1-555-9999",
607 * "address": "456 New Address",
608 * "city": "Sydney",
609 * "state": "NSW",
610 * "postal_code": "2001",
611 * "country": "Australia",
612 * "notes": "Updated primary contact",
613 * "status": "active"
614 * }' \
615 * "https://api.everydaytech.au/customers/42"
616 * @apiExample {json} Success Response:
617 * HTTP/1.1 200 OK
618 * {
619 * "customer_id": 42,
620 * "name": "Acme Corporation Pty Ltd",
621 * "contact_name": "Jane Smith",
622 * "updated_at": "2026-02-10T16:45:00Z",
623 * ...
624 * }
625 * @since 2.0.0
626 * @see {@link module:routes/customers.getCustomer} for retrieving current customer state
627 */
628router.put('/:id', async (req, res) => {
629 const customerId = req.params.id;
630 const { name, contact_name, email, phone, address, city, state, postal_code, country, notes, status } = req.body;
631
632 try {
633 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
634
635 // Build the update query with tenant filtering
636 const updateParams = [name, contact_name, email, phone, address, city, state, postal_code, country, notes, status];
637
638 // Add customer ID
639 const customerIdParamIndex = 12;
640 updateParams.push(customerId);
641
642 // Build WHERE clause
643 let whereClause = `customer_id = $${customerIdParamIndex}`;
644
645 if (tenantClause) {
646 // Add tenant params and adjust clause
647 updateParams.push(...tenantParams);
648 const tenantParamStart = 13;
649 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (match, num) => {
650 return `$${tenantParamStart + parseInt(num) - 1}`;
651 });
652 whereClause += ` AND ${adjustedClause}`;
653 }
654
655 const result = await pool.query(
656 `UPDATE customers c SET name=$1, contact_name=$2, email=$3, phone=$4, address=$5, city=$6,
657 state=$7, postal_code=$8, country=$9, notes=$10, status=$11, updated_at=NOW()
658 WHERE ${whereClause} RETURNING *`,
659 updateParams
660 );
661
662 if (result.rows.length === 0) {
663 return res.status(404).json({ error: 'Customer not found' });
664 }
665 res.json(result.rows[0]);
666 } catch (err) {
667 console.error('Error updating customer:', err);
668 res.status(500).json({ error: 'Failed to update customer', details: err.message });
669 }
670});
671
672// Customer Contacts CRUD
673
674/**
675 * @api {post} /customers/:id/contacts Create customer contact
676 * @apiName CreateCustomerContact
677 * @apiGroup Customer Contacts
678 * @apiDescription Creates a new contact person for a customer. Supports primary contact
679 * designation - if is_primary is true, automatically unsets primary flag on other contacts
680 * for this customer to ensure only one primary contact exists.
681 *
682 * **Contact Hierarchy:**
683 * - Primary contact: Main point of contact (only one per customer)
684 * - Secondary contacts: Additional contacts (unlimited)
685 *
686 * **Primary Contact Handling:**
687 * When is_primary=true, automatically sets is_primary=false on all other contacts
688 * for the customer before creating the new primary contact.
689 * @apiHeader {string} Authorization Bearer JWT token with tenant context
690 * @apiHeader {string} Content-Type application/json
691 * @apiParam {number} id Customer ID (URL parameter)
692 * @apiParam {string} name Contact person name (required)
693 * @apiParam {string} [role] Job title or role (e.g., "IT Manager")
694 * @apiParam {string} [email] Contact email address
695 * @apiParam {string} [phone] Contact phone number
696 * @apiParam {boolean} [is_primary=false] Whether this is the primary contact
697 * @apiParam {string} [notes] Internal notes about contact
698 * @apiSuccess {Object} contact Newly created contact object
699 * @apiSuccess {number} contact.contact_id Auto-generated contact ID
700 * @apiError {number} 400 Missing required field (name)
701 * @apiError {number} 500 Failed to create contact
702 * @apiExample {curl} Example Request:
703 * curl -X POST \
704 * -H "Authorization: Bearer eyJhbGc..." \
705 * -H "Content-Type: application/json" \
706 * -d '{
707 * "name": "Jane Smith",
708 * "role": "IT Manager",
709 * "email": "jane@acme.com",
710 * "phone": "+1-555-5678",
711 * "is_primary": true,
712 * "notes": "Technical escalation contact"
713 * }' \
714 * "https://api.everydaytech.au/customers/42/contacts"
715 * @apiExample {json} Success Response:
716 * HTTP/1.1 201 Created
717 * {
718 * "contact_id": 15,
719 * "customer_id": 42,
720 * "name": "Jane Smith",
721 * "role": "IT Manager",
722 * "email": "jane@acme.com",
723 * "phone": "+1-555-5678",
724 * "is_primary": true,
725 * "tenant_id": 1,
726 * "created_at": "2026-02-10T15:00:00Z"
727 * }
728 * @since 2.0.0
729 * @see {@link module:routes/customers.updateContact} for updating contacts
730 * @see {@link module:routes/customers.getCustomer} to view all contacts
731 */
732router.post('/:id/contacts', async (req, res) => {
733 const customerId = req.params.id;
734 const { name, role = null, email = null, phone = null, is_primary = false, notes = null } = req.body;
735
736 console.log('POST /customers/:id/contacts called');
737 console.log('Customer ID:', customerId);
738 console.log('Request body:', req.body);
739
740 if (!name) {
741 console.log('Error: Contact name is required');
742 return res.status(400).json({ error: 'Contact name is required' });
743 }
744
745 try {
746 // If setting as primary, unset other primaries for this customer
747 if (is_primary) {
748 await pool.query(
749 'UPDATE customer_contacts SET is_primary = false WHERE customer_id = $1',
750 [customerId]
751 );
752 }
753
754 const result = await pool.query(
755 `INSERT INTO customer_contacts (customer_id, name, role, email, phone, is_primary, notes, tenant_id)
756 VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
757 [customerId, name, role, email, phone, is_primary, notes, req.tenant?.id]
758 );
759 console.log('Contact created successfully:', result.rows[0]);
760 console.log('Tenant context in contacts route:', req.tenant);
761
762 res.status(201).json(result.rows[0]);
763 } catch (err) {
764 console.error('Error creating customer contact:', err);
765 res.status(500).json({ error: 'Failed to create contact', details: err.message });
766 }
767});
768
769/**
770 * @api {put} /customers/:id/contacts/:contactId Update customer contact
771 * @apiName UpdateCustomerContact
772 * @apiGroup Customer Contacts
773 * @apiDescription Updates an existing customer contact's details. If is_primary is set
774 * to true, automatically unsets primary flag on other contacts for the same customer.
775 * Sets updated_at timestamp automatically.
776 *
777 * **Primary Contact Toggle:**
778 * When changing is_primary from false to true, the system ensures only one primary
779 * contact exists by setting is_primary=false on all other contacts for this customer
780 * (excluding the contact being updated).
781 * @apiHeader {string} Authorization Bearer JWT token with tenant context
782 * @apiHeader {string} Content-Type application/json
783 * @apiParam {number} id Customer ID (URL parameter)
784 * @apiParam {number} contactId Contact ID to update (URL parameter)
785 * @apiParam {string} name Updated contact name
786 * @apiParam {string} role Updated role/title
787 * @apiParam {string} email Updated email
788 * @apiParam {string} phone Updated phone
789 * @apiParam {boolean} is_primary Updated primary status
790 * @apiParam {string} notes Updated notes
791 * @apiSuccess {Object} contact Updated contact object with new updated_at timestamp
792 * @apiError {number} 404 Contact not found
793 * @apiError {number} 500 Failed to update contact
794 * @apiExample {curl} Example Request:
795 * curl -X PUT \
796 * -H "Authorization: Bearer eyJhbGc..." \
797 * -H "Content-Type: application/json" \
798 * -d '{
799 * "name": "Jane Smith",
800 * "role": "Director of IT",
801 * "email": "jane.smith@acme.com",
802 * "phone": "+1-555-9999",
803 * "is_primary": true,
804 * "notes": "Promoted to Director"
805 * }' \
806 * "https://api.everydaytech.au/customers/42/contacts/15"
807 * @apiExample {json} Success Response:
808 * HTTP/1.1 200 OK
809 * {
810 * "contact_id": 15,
811 * "customer_id": 42,
812 * "name": "Jane Smith",
813 * "role": "Director of IT",
814 * "is_primary": true,
815 * "updated_at": "2026-02-10T16:30:00Z",
816 * ...
817 * }
818 * @since 2.0.0
819 * @see {@link module:routes/customers.createContact} for creating contacts
820 */
821router.put('/:id/contacts/:contactId', async (req, res) => {
822 const customerId = req.params.id;
823 const contactId = req.params.contactId;
824 const { name, role, email, phone, is_primary, notes } = req.body;
825
826 try {
827 // If setting as primary, unset other primaries for this customer
828 if (is_primary) {
829 await pool.query(
830 'UPDATE customer_contacts SET is_primary = false WHERE customer_id = $1 AND contact_id != $2',
831 [customerId, contactId]
832 );
833 }
834
835 const result = await pool.query(
836 `UPDATE customer_contacts
837 SET name=$1, role=$2, email=$3, phone=$4, is_primary=$5, notes=$6, updated_at=NOW()
838 WHERE contact_id=$7 AND customer_id=$8 RETURNING *`,
839 [name, role, email, phone, is_primary, notes, contactId, customerId]
840 );
841
842 if (result.rows.length === 0) {
843 return res.status(404).json({ error: 'Contact not found' });
844 }
845 res.json(result.rows[0]);
846 } catch (err) {
847 console.error('Error updating customer contact:', err);
848 res.status(500).json({ error: 'Failed to update contact', details: err.message });
849 }
850});
851
852/**
853 * @api {delete} /customers/:id/contacts/:contactId Delete customer contact
854 * @apiName DeleteCustomerContact
855 * @apiGroup Customer Contacts
856 * @apiDescription Permanently deletes a customer contact. This action cannot be undone.
857 * Use with caution. Consider updating rather than deleting if contact information changes.
858 *
859 * **Deletion Safety:**
860 * - Validates contact belongs to specified customer
861 * - Returns deleted contact object for audit trail
862 * - No cascading effects on customer or other records
863 *
864 * **Best Practice:**
865 * If simply changing primary contact, use the update endpoint instead of delete+create.
866 * @apiHeader {string} Authorization Bearer JWT token with tenant context
867 * @apiParam {number} id Customer ID (URL parameter)
868 * @apiParam {number} contactId Contact ID to delete (URL parameter)
869 * @apiSuccess {string} message Success confirmation message
870 * @apiSuccess {object} contact The deleted contact object (for audit trail)
871 * @apiError {number} 404 Contact not found or doesn't belong to customer
872 * @apiError {number} 500 Failed to delete contact
873 * @apiExample {curl} Example Request:
874 * curl -X DELETE \
875 * -H "Authorization: Bearer eyJhbGc..." \
876 * "https://api.everydaytech.au/customers/42/contacts/15"
877 * @apiExample {json} Success Response:
878 * HTTP/1.1 200 OK
879 * {
880 * "message": "Contact deleted successfully",
881 * "contact": {
882 * "contact_id": 15,
883 * "customer_id": 42,
884 * "name": "Former Employee",
885 * "email": "old@acme.com"
886 * }
887 * }
888 * @since 2.0.0
889 * @see {@link module:routes/customers.updateContact} for updating instead of deleting
890 */
891router.delete('/:id/contacts/:contactId', async (req, res) => {
892 const customerId = req.params.id;
893 const contactId = req.params.contactId;
894
895 try {
896 const result = await pool.query(
897 'DELETE FROM customer_contacts WHERE contact_id = $1 AND customer_id = $2 RETURNING *',
898 [contactId, customerId]
899 );
900
901 if (result.rows.length === 0) {
902 return res.status(404).json({ error: 'Contact not found' });
903 }
904 res.json({ message: 'Contact deleted successfully', contact: result.rows[0] });
905 } catch (err) {
906 console.error('Error deleting customer contact:', err);
907 res.status(500).json({ error: 'Failed to delete contact', details: err.message });
908 }
909});
910
911/**
912 * @api {delete} /customers/:id Delete customer
913 * @apiName DeleteCustomer
914 * @apiGroup Customers
915 * @apiDescription Permanently deletes a customer and associated contacts. Admin-only operation
916 * with referential integrity checks to prevent deletion of customers with active tickets or
917 * invoices. Use with extreme caution as this action cannot be undone.
918 *
919 * **Admin Only:**
920 * Requires admin role via requireAdmin middleware. Regular users cannot delete customers.
921 *
922 * **Referential Integrity Protection:**
923 * Prevents deletion if customer has:
924 * - Any tickets (open or closed)
925 * - Any invoices (paid or unpaid)
926 *
927 * Returns error with counts if deletion blocked. Recommend setting status to 'inactive' instead.
928 *
929 * **Cascade Deletion:**
930 * If deletion proceeds, automatically deletes:
931 * - All customer contacts (customer_contacts table)
932 * - The customer record itself
933 *
934 * **Best Practice:**
935 * Instead of deleting customers with business history, set status='inactive' using the
936 * update endpoint. This preserves historical data and audit trails while hiding customer
937 * from active lists.
938 * @apiHeader {string} Authorization Bearer JWT token with admin permissions
939 * @apiParam {number} id Customer ID to delete (URL parameter)
940 * @apiSuccess {string} message Success confirmation message
941 * @apiSuccess {object} deleted_customer The deleted customer object
942 * @apiError {number} 403 Forbidden - Admin role required
943 * @apiError {number} 404 Customer not found or access denied
944 * @apiError {number} 400 Cannot delete customer with existing tickets/invoices
945 * @apiError {number} 500 Failed to delete customer
946 * @apiExample {curl} Example Request:
947 * curl -X DELETE \
948 * -H "Authorization: Bearer eyJhbGc..." \
949 * "https://api.everydaytech.au/customers/999"
950 * @apiExample {json} Success Response:
951 * HTTP/1.1 200 OK
952 * {
953 * "message": "Customer deleted successfully",
954 * "deleted_customer": {
955 * "customer_id": 999,
956 * "name": "Test Customer Ltd",
957 * "status": "inactive"
958 * }
959 * }
960 * @apiExample {json} Error Response (Has Related Records):
961 * HTTP/1.1 400 Bad Request
962 * {
963 * "error": "Cannot delete customer with existing tickets or invoices. Set status to inactive instead.",
964 * "details": {
965 * "ticket_count": 5,
966 * "invoice_count": 12
967 * }
968 * }
969 * @apiExample {json} Error Response (Not Admin):
970 * HTTP/1.1 403 Forbidden
971 * {
972 * "error": "Admin access required"
973 * }
974 * @since 2.0.0
975 * @see {@link module:routes/customers.updateCustomer} for setting status to inactive (preferred)
976 */
977router.delete('/:id', requireAdmin, async (req, res) => {
978 const customerId = req.params.id;
979
980 try {
981 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
982
983 // First check if customer exists and user has access
984 let checkQuery = 'SELECT customer_id, name FROM customers c WHERE c.customer_id = $1';
985 const checkParams = [customerId];
986
987 if (tenantClause) {
988 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
989 checkQuery += ` AND ${adjustedClause}`;
990 checkParams.push(...tenantParams);
991 }
992
993 const checkResult = await pool.query(checkQuery, checkParams);
994
995 if (checkResult.rows.length === 0) {
996 return res.status(404).json({ error: 'Customer not found' });
997 }
998
999 const customer = checkResult.rows[0];
1000
1001 // Delete associated customer contacts first (due to foreign key constraints)
1002 try {
1003 await pool.query('DELETE FROM customer_contacts WHERE customer_id = $1', [customerId]);
1004 } catch (contactErr) {
1005 console.log('No customer contacts to delete or table does not exist');
1006 }
1007
1008 // Check for related tickets or invoices that would prevent deletion
1009 const relatedCheck = await pool.query(`
1010 SELECT
1011 (SELECT COUNT(*) FROM tickets WHERE customer_id = $1) as ticket_count,
1012 (SELECT COUNT(*) FROM invoices WHERE customer_id = $1) as invoice_count
1013 `, [customerId]);
1014
1015 const { ticket_count, invoice_count } = relatedCheck.rows[0];
1016
1017 if (parseInt(ticket_count) > 0 || parseInt(invoice_count) > 0) {
1018 return res.status(400).json({
1019 error: 'Cannot delete customer with existing tickets or invoices. Set status to inactive instead.',
1020 details: { ticket_count: parseInt(ticket_count), invoice_count: parseInt(invoice_count) }
1021 });
1022 }
1023
1024 // Delete the customer
1025 const result = await pool.query(
1026 'DELETE FROM customers WHERE customer_id = $1 RETURNING *',
1027 [customerId]
1028 );
1029
1030 console.log(`[Customers] Customer deleted: ID ${customerId}, Name: "${customer.name}", User: ${req.user?.user_id}, Tenant: ${req.tenant?.id}`);
1031
1032 res.json({
1033 message: 'Customer deleted successfully',
1034 deleted_customer: result.rows[0]
1035 });
1036 } catch (err) {
1037 console.error('Error deleting customer:', err);
1038 res.status(500).json({ error: 'Failed to delete customer', details: err.message });
1039 }
1040});
1041
1042module.exports = router;
1043