enterprisecargo / src /services /adminService.js
vish85521's picture
Upload 64 files
eeb3436 verified
Raw
History Blame Contribute Delete
16.9 kB
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,
};