const bcrypt = require("bcryptjs"); const { withTransaction } = require("../db/transaction"); const { AppError } = require("../utils/appError"); const { assertPasswordPolicy } = require("../utils/passwordPolicy"); const BCRYPT_SALT_ROUNDS = 12; function normalizeRoleCodes(raw) { if (!raw) { return []; } if (!Array.isArray(raw)) { throw new AppError("roleCodes must be an array", 400); } return raw .map((value) => String(value).trim()) .filter(Boolean) .map((value) => value.toLowerCase()) .filter((value, index, self) => self.indexOf(value) === index); } function coerceUserStatus(raw) { const normalized = (raw || "ACTIVE").toString().toUpperCase(); if (!["ACTIVE", "DISABLED"].includes(normalized)) { throw new AppError("status must be ACTIVE or DISABLED", 400); } return normalized; } function isSuperAdminContext(context) { return Boolean(context?.isSuperAdmin); } function assertTenantAccess(context, tenantCode) { if (isSuperAdminContext(context)) { return; } const actorTenantCode = context?.actorTenantCode || null; if (!actorTenantCode) { throw new AppError("Authenticated actor has no tenant scope", 403); } if (tenantCode !== actorTenantCode) { throw new AppError("Cross-tenant user administration is forbidden", 403, { code: "TENANT_SCOPE_VIOLATION", actorTenantCode, tenantCode, }); } } async function assertRoleCodesAreValid(executor, adminRepository, roleCodes) { if (!roleCodes || roleCodes.length === 0) { return []; } const existing = await adminRepository.getExistingRoleCodes(executor, roleCodes); const existingSet = new Set(existing); const missing = roleCodes.filter((roleCode) => !existingSet.has(roleCode)); if (missing.length > 0) { throw new AppError(`Unknown role codes: ${missing.join(", ")}`, 400, { code: "ROLE_CODE_INVALID", missing, }); } return roleCodes; } function assertRoleAssignmentAllowed(context, roleCodes) { if (isSuperAdminContext(context)) { return; } if (roleCodes.includes("super_admin")) { throw new AppError("Only super_admin can assign super_admin role", 403, { code: "ROLE_ASSIGNMENT_FORBIDDEN", }); } } function coerceAssignmentStatus(raw) { if (raw === undefined || raw === null || raw === "") { return null; } const normalized = String(raw).toUpperCase(); if (!["ACTIVE", "ENDED"].includes(normalized)) { throw new AppError("status must be ACTIVE or ENDED", 400); } return normalized; } function resolveTenantCode(context, inputTenantCode) { const tenantCode = String(inputTenantCode || context?.actorTenantCode || "").trim(); if (!tenantCode) { throw new AppError("tenantCode is required", 400); } return tenantCode; } function createAdminService(deps) { const { pool, adminRepository, auditRepository } = deps; async function listTenants(query) { return adminRepository.listTenants(pool, Math.min(Number(query.limit) || 500, 2000)); } async function listUsers(query) { return adminRepository.listUsers(pool, { tenantCode: query.tenantCode || null, limit: Math.min(Number(query.limit) || 500, 2000), }); } async function listRoles() { return adminRepository.listRoles(pool); } async function createUser(input, context) { if (!input || typeof input !== "object") { throw new AppError("Request body must be a JSON object", 400); } if (!input.email) { throw new AppError("email is required", 400); } if (!input.fullName) { throw new AppError("fullName is required", 400); } if (!input.password) { throw new AppError("password is required", 400); } const tenantCode = String(input.tenantCode || context?.actorTenantCode || "").trim(); if (!tenantCode) { throw new AppError("tenantCode is required", 400); } const roleCodes = normalizeRoleCodes(input.roleCodes); if (roleCodes.length === 0) { throw new AppError("roleCodes must include at least one role", 400); } const status = coerceUserStatus(input.status); const isActive = input.isActive === undefined ? true : Boolean(input.isActive); assertPasswordPolicy(String(input.password)); assertRoleAssignmentAllowed(context, roleCodes); const tenant = await adminRepository.findTenantByCode(pool, tenantCode); if (!tenant) { throw new AppError("Tenant not found", 404); } if (!tenant.is_active) { throw new AppError("Tenant is inactive", 403, { code: "TENANT_INACTIVE", }); } assertTenantAccess(context, tenant.tenant_code); const passwordHash = await bcrypt.hash(String(input.password), BCRYPT_SALT_ROUNDS); try { return await withTransaction(pool, async (client) => { const validRoleCodes = await assertRoleCodesAreValid(client, adminRepository, roleCodes); const user = await adminRepository.createUser(client, { tenantId: tenant.id, email: String(input.email).toLowerCase(), fullName: String(input.fullName), passwordHash, status, isActive, }); const roles = await adminRepository.replaceUserRoles(client, { tenantId: tenant.id, userId: user.id, roleCodes: validRoleCodes, }); await auditRepository.insertAuditLog(client, { tenantId: tenant.id, actorUserId: context.actorUserId, action: "ADMIN_USER_CREATE", targetType: "user", targetId: user.id, metadata: { email: user.email, roleCodes: roles, }, ipAddress: context.ipAddress, userAgent: context.userAgent, }); return { ...user, tenantCode: tenant.tenant_code, roles, }; }); } catch (error) { if (error && error.code === "23505") { throw new AppError("User with this email already exists for the tenant", 409); } throw error; } } async function patchUser(userId, input, context) { if (!input || typeof input !== "object") { throw new AppError("Request body must be a JSON object", 400); } const roleCodes = input.roleCodes === undefined ? undefined : normalizeRoleCodes(input.roleCodes); if (roleCodes !== undefined) { if (roleCodes.length === 0) { throw new AppError("roleCodes must include at least one role", 400); } assertRoleAssignmentAllowed(context, roleCodes); } if (input.password !== undefined) { assertPasswordPolicy(String(input.password)); } const patch = { fullName: input.fullName !== undefined ? String(input.fullName) : undefined, status: input.status !== undefined ? coerceUserStatus(input.status) : undefined, isActive: input.isActive !== undefined ? Boolean(input.isActive) : undefined, passwordHash: input.password !== undefined ? await bcrypt.hash(String(input.password), BCRYPT_SALT_ROUNDS) : undefined, }; return withTransaction(pool, async (client) => { const existing = await adminRepository.getUserById(client, userId); if (!existing) { throw new AppError("User not found", 404); } assertTenantAccess(context, existing.tenant_code); if (!isSuperAdminContext(context) && normalizeRoleCodes(existing.roles).includes("super_admin")) { throw new AppError("Only super_admin can modify a super_admin user", 403, { code: "SUPER_ADMIN_PROTECTED", }); } const updated = await adminRepository.patchUser(client, userId, patch); if (!updated) { throw new AppError("User not found", 404); } let roles = normalizeRoleCodes(existing.roles); if (roleCodes !== undefined) { const validRoleCodes = await assertRoleCodesAreValid(client, adminRepository, roleCodes); roles = await adminRepository.replaceUserRoles(client, { tenantId: existing.tenant_id, userId, roleCodes: validRoleCodes, }); } await auditRepository.insertAuditLog(client, { tenantId: existing.tenant_id, actorUserId: context.actorUserId, action: "ADMIN_USER_UPDATE", targetType: "user", targetId: existing.id, metadata: { fullNameChanged: patch.fullName !== undefined, statusChanged: patch.status !== undefined, isActiveChanged: patch.isActive !== undefined, passwordChanged: patch.passwordHash !== undefined, roles, }, ipAddress: context.ipAddress, userAgent: context.userAgent, }); return { ...updated, tenantCode: existing.tenant_code, roles, }; }); } async function resetUserPassword(userId, input, context) { if (!input || typeof input !== "object") { throw new AppError("Request body must be a JSON object", 400); } const newPassword = String(input.newPassword || ""); if (!newPassword) { throw new AppError("newPassword is required", 400); } assertPasswordPolicy(newPassword); return withTransaction(pool, async (client) => { const existing = await adminRepository.getUserById(client, userId); if (!existing) { throw new AppError("User not found", 404); } assertTenantAccess(context, existing.tenant_code); if (!isSuperAdminContext(context) && normalizeRoleCodes(existing.roles).includes("super_admin")) { throw new AppError("Only super_admin can reset password of a super_admin user", 403, { code: "SUPER_ADMIN_PROTECTED", }); } const passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS); const updated = await adminRepository.updateUserPassword(client, userId, passwordHash); if (!updated) { throw new AppError("User not found", 404); } await auditRepository.insertAuditLog(client, { tenantId: existing.tenant_id, actorUserId: context.actorUserId, action: "ADMIN_USER_PASSWORD_RESET", targetType: "user", targetId: existing.id, metadata: { email: existing.email, initiatedByRole: context.actorRoles || [], }, ipAddress: context.ipAddress, userAgent: context.userAgent, }); return { id: existing.id, tenantCode: existing.tenant_code, email: existing.email, passwordReset: true, updatedAt: updated.updated_at, }; }); } async function listDeviceRegistry(query) { return adminRepository.listDeviceRegistry(pool, { tenantCode: query.tenantCode || null, limit: Math.min(Number(query.limit) || 500, 2000), }); } async function listAuditLogs(query) { return adminRepository.listAuditLogs(pool, { tenantCode: query.tenantCode || null, limit: Math.min(Number(query.limit) || 500, 2000), }); } async function listFleetManagers(query, context) { const tenantCode = resolveTenantCode(context, query.tenantCode); assertTenantAccess(context, tenantCode); return adminRepository.listFleetManagers(pool, { tenantCode, limit: Math.min(Number(query.limit) || 500, 2000), }); } async function listAssignablePairs(query, context) { const tenantCode = resolveTenantCode(context, query.tenantCode); assertTenantAccess(context, tenantCode); return adminRepository.listAssignablePairs(pool, { tenantCode, limit: Math.min(Number(query.limit) || 500, 2000), }); } async function listFleetManagerAssignments(query, context) { const tenantCode = resolveTenantCode(context, query.tenantCode); assertTenantAccess(context, tenantCode); const status = coerceAssignmentStatus(query.status); return adminRepository.listFleetManagerAssignments(pool, { tenantCode, status, limit: Math.min(Number(query.limit) || 500, 2000), }); } async function createFleetManagerAssignment(input, context) { if (!input || typeof input !== "object") { throw new AppError("Request body must be a JSON object", 400); } const tenantCode = resolveTenantCode(context, input.tenantCode); const truckId = String(input.truckId || "").trim(); const containerId = String(input.containerId || "").trim(); const managerUserId = String(input.managerUserId || "").trim(); const notes = input.notes ? String(input.notes) : null; if (!truckId || !containerId || !managerUserId) { throw new AppError("truckId, containerId, and managerUserId are required", 400); } const tenant = await adminRepository.findTenantByCode(pool, tenantCode); if (!tenant) { throw new AppError("Tenant not found", 404); } if (!tenant.is_active) { throw new AppError("Tenant is inactive", 403, { code: "TENANT_INACTIVE", }); } assertTenantAccess(context, tenant.tenant_code); return withTransaction(pool, async (client) => { const manager = await adminRepository.getUserById(client, managerUserId); if (!manager) { throw new AppError("Fleet manager not found", 404); } if (manager.tenant_id !== tenant.id) { throw new AppError("Fleet manager does not belong to tenant", 400); } const roleCodes = normalizeRoleCodes(manager.roles); if (!roleCodes.includes("fleet_manager")) { throw new AppError("Selected user is not a fleet manager", 400); } if (!manager.is_active || String(manager.status).toUpperCase() !== "ACTIVE") { throw new AppError("Fleet manager account is disabled", 400); } const activePair = await adminRepository.getActiveTruckContainerAssignment(client, { tenantId: tenant.id, truckId, containerId, }); if (!activePair) { throw new AppError("Active truck/container assignment not found", 404); } let created; try { created = await adminRepository.createFleetManagerAssignment(client, { tenantId: tenant.id, truckId, containerId, managerUserId, assignedByUserId: context.actorUserId, notes, }); } catch (error) { if (error && error.code === "23505") { throw new AppError("Container already assigned to a fleet manager", 409); } throw error; } await auditRepository.insertAuditLog(client, { tenantId: tenant.id, actorUserId: context.actorUserId, action: "FLEET_MANAGER_ASSIGNMENT_CREATE", targetType: "fleet_manager_assignment", targetId: created.id, metadata: { truckId, containerId, managerUserId, notes, }, ipAddress: context.ipAddress, userAgent: context.userAgent, }); return created; }); } async function endFleetManagerAssignment(assignmentId, input, context) { if (!assignmentId) { throw new AppError("assignmentId is required", 400); } const payload = input && typeof input === "object" ? input : {}; const notes = payload.notes ? String(payload.notes) : null; return withTransaction(pool, async (client) => { const existing = await adminRepository.getFleetManagerAssignmentById(client, assignmentId); if (!existing) { throw new AppError("Assignment not found", 404); } assertTenantAccess(context, existing.tenant_code); if (existing.status !== "ACTIVE" || existing.unassigned_at) { throw new AppError("Assignment is already ended", 409); } const updated = await adminRepository.endFleetManagerAssignment(client, assignmentId, { notes, }); if (!updated) { throw new AppError("Assignment not found", 404); } await auditRepository.insertAuditLog(client, { tenantId: existing.tenant_id, actorUserId: context.actorUserId, action: "FLEET_MANAGER_ASSIGNMENT_END", targetType: "fleet_manager_assignment", targetId: existing.id, metadata: { managerUserId: existing.manager_user_id, containerId: existing.container_id, truckId: existing.truck_id, notes, }, ipAddress: context.ipAddress, userAgent: context.userAgent, }); return updated; }); } return { listTenants, listRoles, listUsers, createUser, patchUser, resetUserPassword, listDeviceRegistry, listAuditLogs, listFleetManagers, listAssignablePairs, listFleetManagerAssignments, createFleetManagerAssignment, endFleetManagerAssignment, }; } module.exports = { createAdminService, };