2 * @file customerUsers.js
3 * @description Customer Portal User Management Routes
5 * Provides admin interface for managing customer portal users. Allows MSP/tenant admins
6 * to create, view, update, and delete customer portal login accounts.
8 * **Permission Model:**
9 * - Admin only: All operations require admin role
10 * - Tenant isolation: Admins can only manage users in their tenant
11 * - Root MSP: Can manage users across all tenants
14 * - Create customer portal users with bcrypt password hashing
15 * - Reset passwords (admin-initiated)
16 * - Toggle user active status (soft delete)
17 * - List users by customer
18 * - Update user details
21 * - Passwords hashed with bcrypt (10 rounds)
22 * - Email uniqueness enforced globally
23 * - Audit logging for all actions
24 * - Admin-only access with tenant filtering
26 * @module routes/customerUsers
29 * @requires services/db
30 * @requires middleware/auth
31 * @requires middleware/tenant
32 * @author Independent Business Group
34 * @see module:routes/portalAuth
35 * @see module:routes/portalCustomer
38const express = require('express');
39const router = express.Router();
40const bcrypt = require('bcrypt');
41const pool = require('../services/db');
42const authenticateToken = require('../middleware/auth');
43const { setTenantContext } = require('../middleware/tenant');
46 * @api {get} /customer-users List Customer Portal Users
47 * @apiName ListCustomerUsers
48 * @apiGroup CustomerPortalUsers
52 * Returns all portal users for specified customer. Admin only.
53 * Filtered by tenant for non-root users.
55 * @apiPermission admin
57 * @apiHeader {String} Authorization Bearer JWT token (admin required)
59 * @apiParam {Number} customer_id Customer ID (query parameter, required)
61 * @apiSuccess {Object[]} users Array of customer portal users
62 * @apiSuccess {Number} users.customer_user_id User ID
63 * @apiSuccess {Number} users.customer_id Customer ID
64 * @apiSuccess {String} users.email Email address (login username)
65 * @apiSuccess {String} users.first_name First name
66 * @apiSuccess {String} users.last_name Last name
67 * @apiSuccess {String} users.phone Phone number
68 * @apiSuccess {Boolean} users.is_primary Primary contact flag
69 * @apiSuccess {Boolean} users.is_active Account active status
70 * @apiSuccess {Date} users.last_login Last login timestamp
71 * @apiSuccess {Date} users.created_at Account creation timestamp
72 * @apiSuccess {String} users.customer_name Customer name
73 * @apiSuccess {String} users.tenant_name Tenant name
75 * @apiError (400) {String} error customer_id is required
76 * @apiError (403) {String} error Admin access required
77 * @apiError (404) {String} error Customer not found or access denied
78 * @apiError (500) {String} error Failed to fetch customer users
80 * @apiExample {curl} Example Request:
82 * -H "Authorization: Bearer eyJhbGc..." \
83 * "https://api.everydaytech.au/customer-users?customer_id=123"
85 * @apiSuccessExample {json} Success Response:
90 * "customer_user_id": 45,
92 * "email": "user@example.com",
93 * "first_name": "John",
95 * "phone": "1234567890",
98 * "last_login": "2026-03-23T02:30:00Z",
99 * "created_at": "2026-03-20T10:00:00Z",
100 * "customer_name": "Acme Corp",
101 * "tenant_name": "IBG MSP"
106router.get('/', authenticateToken, setTenantContext, async (req, res) => {
109 if (!req.user || req.user.role !== 'admin') {
110 return res.status(403).json({ error: 'Admin access required' });
113 const { customer_id } = req.query;
116 return res.status(400).json({ error: 'customer_id is required' });
119 // Verify customer exists and belongs to tenant
121 if (req.tenant?.isMsp) {
122 customerCheck = await pool.query(
123 'SELECT customer_id FROM customers WHERE customer_id = $1',
127 customerCheck = await pool.query(
128 'SELECT customer_id FROM customers WHERE customer_id = $1 AND tenant_id = $2',
129 [customer_id, req.tenant?.id || req.user?.tenantId]
133 if (customerCheck.rows.length === 0) {
134 return res.status(404).json({ error: 'Customer not found or access denied' });
137 // Get customer portal users
138 const result = await pool.query(
150 c.name as customer_name,
151 t.name as tenant_name
152 FROM customer_users cu
153 JOIN customers c ON cu.customer_id = c.customer_id
154 JOIN tenants t ON cu.tenant_id = t.tenant_id
155 WHERE cu.customer_id = $1
156 ORDER BY cu.is_primary DESC, cu.created_at ASC`,
160 res.json({ users: result.rows });
162 console.error('Error fetching customer users:', err);
163 res.status(500).json({ error: 'Failed to fetch customer users' });
168 * @api {post} /customer-users Create Customer Portal User
169 * @apiName CreateCustomerUser
170 * @apiGroup CustomerPortalUsers
174 * Creates portal login for customer. Admin only.
175 * Password is bcrypt-hashed. Sets tenant_id from customer record.
176 * Logs creation to customer_audit_log.
178 * @apiPermission admin
180 * @apiHeader {String} Authorization Bearer JWT token (admin required)
181 * @apiHeader {String} Content-Type application/json
183 * @apiParam {Number} customer_id Customer ID (required)
184 * @apiParam {String} email Email address (required, unique globally)
185 * @apiParam {String} password Plain text password (required, will be hashed)
186 * @apiParam {String} [first_name] First name (optional)
187 * @apiParam {String} [last_name] Last name (optional)
188 * @apiParam {String} [phone] Phone number (optional)
189 * @apiParam {Boolean} [is_primary=false] Set as primary contact
191 * @apiSuccess (201) {Number} customer_user_id Generated user ID
192 * @apiSuccess (201) {String} tenant_id Tenant UUID
193 * @apiSuccess (201) {Number} customer_id Customer ID
194 * @apiSuccess (201) {String} email Email address
195 * @apiSuccess (201) {String} first_name First name
196 * @apiSuccess (201) {String} last_name Last name
197 * @apiSuccess (201) {String} phone Phone number
198 * @apiSuccess (201) {Boolean} is_primary Primary flag
199 * @apiSuccess (201) {Boolean} is_active Active status (always true)
200 * @apiSuccess (201) {Date} created_at Creation timestamp
202 * @apiError (400) {String} error customer_id, email, and password are required
203 * @apiError (403) {String} error Admin access required
204 * @apiError (404) {String} error Customer not found or access denied
205 * @apiError (409) {String} error Email already exists
206 * @apiError (500) {String} error Failed to create customer user
208 * @apiExample {curl} Example Request:
210 * -H "Authorization: Bearer eyJhbGc..." \
211 * -H "Content-Type: application/json" \
213 * "customer_id": 123,
214 * "email": "user@example.com",
215 * "password": "SecurePass123",
216 * "first_name": "John",
219 * "https://api.everydaytech.au/customer-users"
221 * @apiSuccessExample {json} Success Response:
222 * HTTP/1.1 201 Created
224 * "customer_user_id": 45,
225 * "tenant_id": "...",
226 * "customer_id": 123,
227 * "email": "user@example.com",
228 * "first_name": "John",
229 * "last_name": "Doe",
231 * "is_primary": false,
233 * "created_at": "2026-03-23T02:30:00Z"
236router.post('/', authenticateToken, setTenantContext, async (req, res) => {
239 if (!req.user || req.user.role !== 'admin') {
240 return res.status(403).json({ error: 'Admin access required' });
254 if (!customer_id || !email || !password) {
255 return res.status(400).json({
256 error: 'customer_id, email, and password are required'
260 // Verify customer exists and belongs to tenant
262 if (req.tenant?.isMsp) {
263 customerCheck = await pool.query(
264 'SELECT customer_id, tenant_id FROM customers WHERE customer_id = $1',
268 customerCheck = await pool.query(
269 'SELECT customer_id, tenant_id FROM customers WHERE customer_id = $1 AND tenant_id = $2',
270 [customer_id, req.tenant?.id || req.user?.tenantId]
274 if (customerCheck.rows.length === 0) {
275 return res.status(404).json({ error: 'Customer not found or access denied' });
278 const tenantId = customerCheck.rows[0].tenant_id;
280 // Check if email already exists
281 const emailCheck = await pool.query(
282 'SELECT customer_user_id FROM customer_users WHERE email = $1',
283 [email.toLowerCase()]
286 if (emailCheck.rows.length > 0) {
287 return res.status(409).json({ error: 'Email already exists' });
291 const passwordHash = await bcrypt.hash(password, 10);
293 // Create customer user
294 const result = await pool.query(
295 `INSERT INTO customer_users
296 (tenant_id, customer_id, email, password_hash, first_name, last_name, phone, is_primary, is_active)
297 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
321 // Log action to customer audit log
323 `INSERT INTO customer_audit_log
324 (customer_user_id, tenant_id, action, resource_type, resource_id, details)
325 VALUES ($1, $2, 'create', 'customer_user', $3, $4)`,
329 result.rows[0].customer_user_id,
331 email: email.toLowerCase(),
333 created_by_admin: req.user.email
338 res.status(201).json(result.rows[0]);
340 console.error('Error creating customer user:', err);
342 if (err.code === '23505') { // Unique violation
343 return res.status(409).json({ error: 'Email already exists' });
346 res.status(500).json({ error: 'Failed to create customer user' });
351 * @api {put} /customer-users/:id Update Customer Portal User
352 * @apiName UpdateCustomerUser
353 * @apiGroup CustomerPortalUsers
357 * Updates user details. Admin only. Cannot update password via this endpoint
358 * (use /reset-password instead). Logs changes to customer_audit_log.
360 * @apiPermission admin
362 * @apiHeader {String} Authorization Bearer JWT token (admin required)
363 * @apiHeader {String} Content-Type application/json
365 * @apiParam {Number} id User ID (URL parameter)
366 * @apiParam {String} [first_name] First name
367 * @apiParam {String} [last_name] Last name
368 * @apiParam {String} [phone] Phone number
369 * @apiParam {String} [email] Email address (must be unique)
371 * @apiSuccess {Number} customer_user_id User ID
372 * @apiSuccess {String} tenant_id Tenant UUID
373 * @apiSuccess {Number} customer_id Customer ID
374 * @apiSuccess {String} email Email address
375 * @apiSuccess {String} first_name First name
376 * @apiSuccess {String} last_name Last name
377 * @apiSuccess {String} phone Phone number
378 * @apiSuccess {Boolean} is_primary Primary flag
379 * @apiSuccess {Boolean} is_active Active status
380 * @apiSuccess {Date} updated_at Update timestamp
382 * @apiError (400) {String} error No fields to update
383 * @apiError (403) {String} error Admin access required
384 * @apiError (404) {String} error User not found or access denied
385 * @apiError (409) {String} error Email already exists
386 * @apiError (500) {String} error Failed to update customer user
388router.put('/:id', authenticateToken, setTenantContext, async (req, res) => {
391 if (!req.user || req.user.role !== 'admin') {
392 return res.status(403).json({ error: 'Admin access required' });
395 const { id } = req.params;
396 const { first_name, last_name, phone, email } = req.body;
398 // Verify user exists and belongs to tenant
400 if (req.tenant?.isMsp) {
401 userCheck = await pool.query(
402 'SELECT customer_user_id, tenant_id, customer_id FROM customer_users WHERE customer_user_id = $1',
406 userCheck = await pool.query(
407 'SELECT customer_user_id, tenant_id, customer_id FROM customer_users WHERE customer_user_id = $1 AND tenant_id = $2',
408 [id, req.tenant?.id || req.user?.tenantId]
412 if (userCheck.rows.length === 0) {
413 return res.status(404).json({ error: 'User not found or access denied' });
416 // If email is being changed, verify it's unique
417 if (email && email !== userCheck.rows[0].email) {
418 const emailCheck = await pool.query(
419 'SELECT customer_user_id FROM customer_users WHERE email = $1 AND customer_user_id != $2',
420 [email.toLowerCase(), id]
423 if (emailCheck.rows.length > 0) {
424 return res.status(409).json({ error: 'Email already exists' });
428 // Build update query dynamically
433 if (first_name !== undefined) {
434 updates.push(`first_name = $${paramCount++}`);
435 values.push(first_name);
437 if (last_name !== undefined) {
438 updates.push(`last_name = $${paramCount++}`);
439 values.push(last_name);
441 if (phone !== undefined) {
442 updates.push(`phone = $${paramCount++}`);
445 if (email !== undefined) {
446 updates.push(`email = $${paramCount++}`);
447 values.push(email.toLowerCase());
450 if (updates.length === 0) {
451 return res.status(400).json({ error: 'No fields to update' });
454 updates.push(`updated_at = NOW()`);
457 const result = await pool.query(
458 `UPDATE customer_users
459 SET ${updates.join(', ')}
460 WHERE customer_user_id = $${paramCount}
477 `INSERT INTO customer_audit_log
478 (customer_user_id, tenant_id, action, resource_type, resource_id, details)
479 VALUES ($1, $2, 'update', 'customer_user', $3, $4)`,
482 userCheck.rows[0].tenant_id,
485 updated_by_admin: req.user.email,
486 changes: { first_name, last_name, phone, email }
491 res.json(result.rows[0]);
493 console.error('Error updating customer user:', err);
495 if (err.code === '23505') {
496 return res.status(409).json({ error: 'Email already exists' });
499 res.status(500).json({ error: 'Failed to update customer user' });
504 * @api {post} /customer-users/:id/reset-password Reset Customer Password
505 * @apiName ResetCustomerUserPassword
506 * @apiGroup CustomerPortalUsers
510 * Admin-initiated password reset. Allows admin to reset customer user password
511 * without knowing current password. Password is bcrypt-hashed. Logs action to
512 * customer_audit_log.
514 * @apiPermission admin
516 * @apiHeader {String} Authorization Bearer JWT token (admin required)
517 * @apiHeader {String} Content-Type application/json
519 * @apiParam {Number} id User ID (URL parameter)
520 * @apiParam {String} new_password New plain text password (required, will be hashed)
522 * @apiSuccess {Boolean} success Operation status (true)
523 * @apiSuccess {String} message Success message
525 * @apiError (400) {String} error new_password is required
526 * @apiError (403) {String} error Admin access required
527 * @apiError (404) {String} error User not found or access denied
528 * @apiError (500) {String} error Failed to reset password
530 * @apiExample {curl} Example Request:
532 * -H "Authorization: Bearer eyJhbGc..." \
533 * -H "Content-Type: application/json" \
534 * -d '{"new_password": "NewSecure456"}' \
535 * "https://api.everydaytech.au/customer-users/45/reset-password"
537router.post('/:id/reset-password', authenticateToken, setTenantContext, async (req, res) => {
540 if (!req.user || req.user.role !== 'admin') {
541 return res.status(403).json({ error: 'Admin access required' });
544 const { id } = req.params;
545 const { new_password } = req.body;
548 return res.status(400).json({ error: 'new_password is required' });
551 // Verify user exists and belongs to tenant
553 if (req.tenant?.isMsp) {
554 userCheck = await pool.query(
555 'SELECT customer_user_id, tenant_id, customer_id, email FROM customer_users WHERE customer_user_id = $1',
559 userCheck = await pool.query(
560 'SELECT customer_user_id, tenant_id, customer_id, email FROM customer_users WHERE customer_user_id = $1 AND tenant_id = $2',
561 [id, req.tenant?.id || req.user?.tenantId]
565 if (userCheck.rows.length === 0) {
566 return res.status(404).json({ error: 'User not found or access denied' });
570 const passwordHash = await bcrypt.hash(new_password, 10);
574 'UPDATE customer_users SET password_hash = $1, updated_at = NOW() WHERE customer_user_id = $2',
580 `INSERT INTO customer_audit_log
581 (customer_user_id, tenant_id, action, resource_type, resource_id, details)
582 VALUES ($1, $2, 'password_reset', 'customer_user', $3, $4)`,
585 userCheck.rows[0].tenant_id,
588 reset_by_admin: req.user.email,
589 user_email: userCheck.rows[0].email,
590 timestamp: new Date().toISOString()
597 message: 'Password reset successfully'
600 console.error('Error resetting password:', err);
601 res.status(500).json({ error: 'Failed to reset password' });
606 * @api {put} /customer-users/:id/status Toggle User Active Status
607 * @apiName ToggleCustomerUserStatus
608 * @apiGroup CustomerPortalUsers
612 * Toggle user active status. Admin only. Soft delete - disables login without
613 * deleting user data. Can be reversed. Logs action to customer_audit_log.
615 * @apiPermission admin
617 * @apiHeader {String} Authorization Bearer JWT token (admin required)
618 * @apiHeader {String} Content-Type application/json
620 * @apiParam {Number} id User ID (URL parameter)
621 * @apiParam {Boolean} is_active Active status (true=enable, false=disable)
623 * @apiSuccess {Number} customer_user_id User ID
624 * @apiSuccess {String} email Email address
625 * @apiSuccess {Boolean} is_active New active status
627 * @apiError (400) {String} error is_active is required
628 * @apiError (403) {String} error Admin access required
629 * @apiError (404) {String} error User not found or access denied
630 * @apiError (500) {String} error Failed to update user status
632router.put('/:id/status', authenticateToken, setTenantContext, async (req, res) => {
635 if (!req.user || req.user.role !== 'admin') {
636 return res.status(403).json({ error: 'Admin access required' });
639 const { id } = req.params;
640 const { is_active } = req.body;
642 if (is_active === undefined) {
643 return res.status(400).json({ error: 'is_active is required' });
646 // Verify user exists and belongs to tenant
648 if (req.tenant?.isMsp) {
649 userCheck = await pool.query(
650 'SELECT customer_user_id, tenant_id, email FROM customer_users WHERE customer_user_id = $1',
654 userCheck = await pool.query(
655 'SELECT customer_user_id, tenant_id, email FROM customer_users WHERE customer_user_id = $1 AND tenant_id = $2',
656 [id, req.tenant?.id || req.user?.tenantId]
660 if (userCheck.rows.length === 0) {
661 return res.status(404).json({ error: 'User not found or access denied' });
665 const result = await pool.query(
666 `UPDATE customer_users
667 SET is_active = $1, updated_at = NOW()
668 WHERE customer_user_id = $2
678 `INSERT INTO customer_audit_log
679 (customer_user_id, tenant_id, action, resource_type, resource_id, details)
680 VALUES ($1, $2, $3, 'customer_user', $4, $5)`,
683 userCheck.rows[0].tenant_id,
684 is_active ? 'activate' : 'deactivate',
687 changed_by_admin: req.user.email,
688 user_email: userCheck.rows[0].email,
689 new_status: is_active
694 res.json(result.rows[0]);
696 console.error('Error updating user status:', err);
697 res.status(500).json({ error: 'Failed to update user status' });
702 * @api {delete} /customer-users/:id Delete Customer Portal User
703 * @apiName DeleteCustomerUser
704 * @apiGroup CustomerPortalUsers
708 * Permanently delete customer portal user. Admin only. Hard delete - removes
709 * user and all associated sessions. **Use with caution** - prefer deactivation
710 * (is_active = false) instead. Logs deletion to customer_audit_log before removal.
712 * @apiPermission admin
714 * @apiHeader {String} Authorization Bearer JWT token (admin required)
716 * @apiParam {Number} id User ID (URL parameter)
718 * @apiSuccess {Boolean} success Operation status (true)
719 * @apiSuccess {String} message Success message
721 * @apiError (403) {String} error Admin access required
722 * @apiError (404) {String} error User not found or access denied
723 * @apiError (500) {String} error Failed to delete customer user
725 * @apiExample {curl} Example Request:
727 * -H "Authorization: Bearer eyJhbGc..." \
728 * "https://api.everydaytech.au/customer-users/45"
730router.delete('/:id', authenticateToken, setTenantContext, async (req, res) => {
733 if (!req.user || req.user.role !== 'admin') {
734 return res.status(403).json({ error: 'Admin access required' });
737 const { id } = req.params;
739 // Verify user exists and belongs to tenant
741 if (req.tenant?.isMsp) {
742 userCheck = await pool.query(
743 'SELECT customer_user_id, tenant_id, email, customer_id FROM customer_users WHERE customer_user_id = $1',
747 userCheck = await pool.query(
748 'SELECT customer_user_id, tenant_id, email, customer_id FROM customer_users WHERE customer_user_id = $1 AND tenant_id = $2',
749 [id, req.tenant?.id || req.user?.tenantId]
753 if (userCheck.rows.length === 0) {
754 return res.status(404).json({ error: 'User not found or access denied' });
757 const user = userCheck.rows[0];
759 // Log action before deletion
761 `INSERT INTO customer_audit_log
762 (customer_user_id, tenant_id, action, resource_type, resource_id, details)
763 VALUES ($1, $2, 'delete', 'customer_user', $3, $4)`,
769 deleted_by_admin: req.user.email,
770 user_email: user.email,
771 customer_id: user.customer_id
776 // Delete user (cascade will delete sessions)
778 'DELETE FROM customer_users WHERE customer_user_id = $1',
784 message: 'Customer user deleted successfully'
787 console.error('Error deleting customer user:', err);
788 res.status(500).json({ error: 'Failed to delete customer user' });
792module.exports = router;