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.
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
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
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
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
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');
50// Apply authentication and tenant context to all routes
51router.use(authenticateToken, setTenantContext);
54 * @api {get} /customers List all customers
55 * @apiName ListCustomers
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.
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)
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:
94 * "name": "Acme Corporation",
95 * "contact_name": "John Smith",
96 * "email": "john@example.com",
97 * "phone": "+1-555-0123",
99 * "tenant_name": "Main MSP",
100 * "created_at": "2025-08-15T10:30:00Z"
106 * @see {@link module:routes/customers.getCustomer} for single customer details
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
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';
124 const { clause: tenantClause, params: tenantParams, nextParamIndex } = getTenantFilter(req, 'c');
126 where.push(tenantClause);
127 params.push(...tenantParams);
129 let paramIndex = nextParamIndex;
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})`);
139 if (status && (status === 'active' || status === 'inactive')) {
141 where.push(`c.status = $${paramIndex}`);
145 // Date range filter (created_at)
147 params.push(dateFrom);
148 where.push(`c.created_at >= $${paramIndex}`);
152 // include entire day for date_to by adding 1 day and using < next day
154 where.push(`c.created_at < ($${paramIndex}::date + INTERVAL '1 day')`);
159 const clause = ' WHERE ' + where.join(' AND ');
161 countQuery += clause;
164 params.push(limit, offset);
165 query += ` ORDER BY c.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
167 const [totalRes, result] = await Promise.all([
168 pool.query(countQuery, params.slice(0, params.length - 2)),
169 pool.query(query, params)
172 res.json({ customers: result.rows, total: parseInt(totalRes.rows[0].count, 10) });
174 console.error('Error fetching customers:', err);
175 res.status(500).json({ error: 'Server error', details: err.message });
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.
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
198 * **Multi-Tenant Isolation:**
199 * All related queries respect tenant boundaries - only returns data accessible
200 * to the authenticated tenant.
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:
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"
234 * { "ticket_id": 125, "title": "Email issues", "status": "open" }
237 * { "invoice_id": 890, "amount": 299.99, "status": "paid" }
240 * { "agent_id": "abc123", "name": "DC01", "status": "online" }
243 * { "contact_id": 1, "name": "John Smith", "is_primary": true }
246 * { "contract_id": 5, "name": "Managed Services", "status": "active" }
249 * { "domain_id": 78, "domain_name": "acme.com", "expires_on": "2027-01-15" }
251 * "office365_subscriptions": [],
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
259router.get('/:id', async (req, res) => {
260 const id = req.params.id;
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';
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);
274 const custRes = await pool.query(query, params);
275 if (custRes.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
277 const customer = custRes.rows[0];
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');
285 const fetchRelatedData = async () => {
293 office365_subscriptions: [],
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}`;
304 ticketQuery += ` AND ${adjustedClause}`;
305 ticketParams.push(...ticketFilter.params);
307 ticketQuery += ' ORDER BY tk.created_at DESC';
308 const ticketsRes = await pool.query(ticketQuery, ticketParams);
309 results.tickets = ticketsRes.rows;
311 console.warn('Error fetching tickets:', err.message);
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}`;
321 invoiceQuery += ` AND ${adjustedClause}`;
322 invoiceParams.push(...invoiceFilter.params);
324 invoiceQuery += ' ORDER BY inv.issued_date DESC';
325 const invoicesRes = await pool.query(invoiceQuery, invoiceParams);
326 results.invoices = invoicesRes.rows;
328 console.warn('Error fetching invoices:', err.message);
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}`;
338 agentQuery += ` AND ${adjustedClause}`;
339 agentParams.push(...agentFilter.params);
341 agentQuery += ' ORDER BY ag.name';
342 const agentsRes = await pool.query(agentQuery, agentParams);
343 results.agents = agentsRes.rows;
345 console.warn('Error fetching agents:', err.message);
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;
352 console.warn('Error fetching contacts:', err.message);
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}`;
362 contractQuery += ` AND ${adjustedClause}`;
363 contractParams.push(...contractFilter.params);
365 contractQuery += ' ORDER BY con.created_at DESC';
366 const contractsRes = await pool.query(contractQuery, contractParams);
367 results.contracts = contractsRes.rows;
369 console.warn('Error fetching contracts:', err.message);
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}`;
381 domainQuery += ` AND ${adjustedClause}`;
382 domainParams.push(...domainsFilter.params);
384 domainQuery += ' ORDER BY d.domain_name';
385 const domainsRes = await pool.query(domainQuery, domainParams);
386 results.domains = domainsRes.rows;
388 console.warn('Error fetching domains:', err.message);
391 // Fetch Office 365 subscriptions
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}`;
400 o365Query += ` AND ${adjustedClause}`;
401 o365Params.push(...o365Filter.params);
403 o365Query += ' ORDER BY o.created_at DESC';
404 const o365Res = await pool.query(o365Query, o365Params);
405 results.office365_subscriptions = o365Res.rows;
407 console.warn('Error fetching office365 subscriptions:', err.message);
410 // Fetch hosting apps
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}`;
419 hostingQuery += ` AND ${adjustedClause}`;
420 hostingParams.push(...hostingFilter.params);
422 hostingQuery += ' ORDER BY h.created_at DESC';
423 const hostingRes = await pool.query(hostingQuery, hostingParams);
424 results.hosting_apps = hostingRes.rows;
426 console.warn('Error fetching hosting apps:', err.message);
432 const relatedData = await fetchRelatedData();
439 console.error('Error fetching customer details:', err);
440 res.status(500).json({ error: 'Server error', details: err.message });
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.).
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
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:
484 * -H "Authorization: Bearer eyJhbGc..." \
485 * -H "Content-Type: application/json" \
487 * "name": "Acme Corporation",
488 * "contact_name": "John Smith",
489 * "email": "john@acme.com",
490 * "phone": "+1-555-0123",
491 * "address": "123 Business Blvd",
494 * "postal_code": "2000",
495 * "country": "Australia",
498 * "https://api.everydaytech.au/customers"
499 * @apiExample {json} Success Response:
500 * HTTP/1.1 201 Created
503 * "name": "Acme Corporation",
504 * "contact_name": "John Smith",
505 * "email": "john@acme.com",
506 * "phone": "+1-555-0123",
507 * "status": "active",
509 * "created_at": "2026-02-10T14:30:00Z",
510 * "updated_at": "2026-02-10T14:30:00Z"
513 * @see {@link module:routes/customers.updateCustomer} for updating customers
514 * @see {@link module:utils/redis} for event publishing
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;
519 console.log('POST /customers - Request body:', req.body);
520 console.log('POST /customers - Tenant context:', req.tenant);
523 return res.status(400).json({ error: 'Customer name is required' });
526 if (!req.tenant?.id) {
527 console.error('POST /customers - No tenant context found');
528 return res.status(403).json({ error: 'No tenant context' });
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]
538 const customer = result.rows[0];
539 console.log('POST /customers - Customer created successfully:', customer);
541 // Publish customer.created event to Redis
543 const redis = require('../utils/redis');
545 type: 'customer.created',
546 tenant_id: customer.tenant_id,
547 customer_id: customer.customer_id,
550 redis.publish('events', JSON.stringify(event));
552 console.error('[Redis] Failed to publish customer.created event:', err);
555 res.status(201).json(customer);
557 console.error('Error creating customer:', err);
558 res.status(500).json({ error: 'Failed to create customer', details: err.message });
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.
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
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:
600 * -H "Authorization: Bearer eyJhbGc..." \
601 * -H "Content-Type: application/json" \
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",
610 * "postal_code": "2001",
611 * "country": "Australia",
612 * "notes": "Updated primary contact",
615 * "https://api.everydaytech.au/customers/42"
616 * @apiExample {json} Success Response:
620 * "name": "Acme Corporation Pty Ltd",
621 * "contact_name": "Jane Smith",
622 * "updated_at": "2026-02-10T16:45:00Z",
626 * @see {@link module:routes/customers.getCustomer} for retrieving current customer state
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;
633 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
635 // Build the update query with tenant filtering
636 const updateParams = [name, contact_name, email, phone, address, city, state, postal_code, country, notes, status];
639 const customerIdParamIndex = 12;
640 updateParams.push(customerId);
642 // Build WHERE clause
643 let whereClause = `customer_id = $${customerIdParamIndex}`;
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}`;
652 whereClause += ` AND ${adjustedClause}`;
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 *`,
662 if (result.rows.length === 0) {
663 return res.status(404).json({ error: 'Customer not found' });
665 res.json(result.rows[0]);
667 console.error('Error updating customer:', err);
668 res.status(500).json({ error: 'Failed to update customer', details: err.message });
672// Customer Contacts CRUD
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.
682 * **Contact Hierarchy:**
683 * - Primary contact: Main point of contact (only one per customer)
684 * - Secondary contacts: Additional contacts (unlimited)
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:
704 * -H "Authorization: Bearer eyJhbGc..." \
705 * -H "Content-Type: application/json" \
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"
714 * "https://api.everydaytech.au/customers/42/contacts"
715 * @apiExample {json} Success Response:
716 * HTTP/1.1 201 Created
720 * "name": "Jane Smith",
721 * "role": "IT Manager",
722 * "email": "jane@acme.com",
723 * "phone": "+1-555-5678",
724 * "is_primary": true,
726 * "created_at": "2026-02-10T15:00:00Z"
729 * @see {@link module:routes/customers.updateContact} for updating contacts
730 * @see {@link module:routes/customers.getCustomer} to view all contacts
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;
736 console.log('POST /customers/:id/contacts called');
737 console.log('Customer ID:', customerId);
738 console.log('Request body:', req.body);
741 console.log('Error: Contact name is required');
742 return res.status(400).json({ error: 'Contact name is required' });
746 // If setting as primary, unset other primaries for this customer
749 'UPDATE customer_contacts SET is_primary = false WHERE customer_id = $1',
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]
759 console.log('Contact created successfully:', result.rows[0]);
760 console.log('Tenant context in contacts route:', req.tenant);
762 res.status(201).json(result.rows[0]);
764 console.error('Error creating customer contact:', err);
765 res.status(500).json({ error: 'Failed to create contact', details: err.message });
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.
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:
796 * -H "Authorization: Bearer eyJhbGc..." \
797 * -H "Content-Type: application/json" \
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"
806 * "https://api.everydaytech.au/customers/42/contacts/15"
807 * @apiExample {json} Success Response:
812 * "name": "Jane Smith",
813 * "role": "Director of IT",
814 * "is_primary": true,
815 * "updated_at": "2026-02-10T16:30:00Z",
819 * @see {@link module:routes/customers.createContact} for creating contacts
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;
827 // If setting as primary, unset other primaries for this customer
830 'UPDATE customer_contacts SET is_primary = false WHERE customer_id = $1 AND contact_id != $2',
831 [customerId, contactId]
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]
842 if (result.rows.length === 0) {
843 return res.status(404).json({ error: 'Contact not found' });
845 res.json(result.rows[0]);
847 console.error('Error updating customer contact:', err);
848 res.status(500).json({ error: 'Failed to update contact', details: err.message });
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.
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
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:
875 * -H "Authorization: Bearer eyJhbGc..." \
876 * "https://api.everydaytech.au/customers/42/contacts/15"
877 * @apiExample {json} Success Response:
880 * "message": "Contact deleted successfully",
884 * "name": "Former Employee",
885 * "email": "old@acme.com"
889 * @see {@link module:routes/customers.updateContact} for updating instead of deleting
891router.delete('/:id/contacts/:contactId', async (req, res) => {
892 const customerId = req.params.id;
893 const contactId = req.params.contactId;
896 const result = await pool.query(
897 'DELETE FROM customer_contacts WHERE contact_id = $1 AND customer_id = $2 RETURNING *',
898 [contactId, customerId]
901 if (result.rows.length === 0) {
902 return res.status(404).json({ error: 'Contact not found' });
904 res.json({ message: 'Contact deleted successfully', contact: result.rows[0] });
906 console.error('Error deleting customer contact:', err);
907 res.status(500).json({ error: 'Failed to delete contact', details: err.message });
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.
920 * Requires admin role via requireAdmin middleware. Regular users cannot delete customers.
922 * **Referential Integrity Protection:**
923 * Prevents deletion if customer has:
924 * - Any tickets (open or closed)
925 * - Any invoices (paid or unpaid)
927 * Returns error with counts if deletion blocked. Recommend setting status to 'inactive' instead.
929 * **Cascade Deletion:**
930 * If deletion proceeds, automatically deletes:
931 * - All customer contacts (customer_contacts table)
932 * - The customer record itself
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
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:
948 * -H "Authorization: Bearer eyJhbGc..." \
949 * "https://api.everydaytech.au/customers/999"
950 * @apiExample {json} Success Response:
953 * "message": "Customer deleted successfully",
954 * "deleted_customer": {
955 * "customer_id": 999,
956 * "name": "Test Customer Ltd",
957 * "status": "inactive"
960 * @apiExample {json} Error Response (Has Related Records):
961 * HTTP/1.1 400 Bad Request
963 * "error": "Cannot delete customer with existing tickets or invoices. Set status to inactive instead.",
966 * "invoice_count": 12
969 * @apiExample {json} Error Response (Not Admin):
970 * HTTP/1.1 403 Forbidden
972 * "error": "Admin access required"
975 * @see {@link module:routes/customers.updateCustomer} for setting status to inactive (preferred)
977router.delete('/:id', requireAdmin, async (req, res) => {
978 const customerId = req.params.id;
981 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
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];
988 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
989 checkQuery += ` AND ${adjustedClause}`;
990 checkParams.push(...tenantParams);
993 const checkResult = await pool.query(checkQuery, checkParams);
995 if (checkResult.rows.length === 0) {
996 return res.status(404).json({ error: 'Customer not found' });
999 const customer = checkResult.rows[0];
1001 // Delete associated customer contacts first (due to foreign key constraints)
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');
1008 // Check for related tickets or invoices that would prevent deletion
1009 const relatedCheck = await pool.query(`
1011 (SELECT COUNT(*) FROM tickets WHERE customer_id = $1) as ticket_count,
1012 (SELECT COUNT(*) FROM invoices WHERE customer_id = $1) as invoice_count
1015 const { ticket_count, invoice_count } = relatedCheck.rows[0];
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) }
1024 // Delete the customer
1025 const result = await pool.query(
1026 'DELETE FROM customers WHERE customer_id = $1 RETURNING *',
1030 console.log(`[Customers] Customer deleted: ID ${customerId}, Name: "${customer.name}", User: ${req.user?.user_id}, Tenant: ${req.tenant?.id}`);
1033 message: 'Customer deleted successfully',
1034 deleted_customer: result.rows[0]
1037 console.error('Error deleting customer:', err);
1038 res.status(500).json({ error: 'Failed to delete customer', details: err.message });
1042module.exports = router;