GWM : handled multiple role for user

This commit is contained in:
Gowtham M 2026-06-22 11:39:28 +05:30
parent 5eb853bcfe
commit c4d2878487
13 changed files with 264 additions and 81 deletions

View File

@ -131,8 +131,8 @@ Base path: `/api/v1` · Auth: `Authorization: Bearer <accessToken>` (except publ
| [x] | GET | `/users/export` | export | CSV export (same filters as list) | | [x] | GET | `/users/export` | export | CSV export (same filters as list) |
| [x] | GET | `/users` | view | Paginated list (`search`, `status`, `role_id`, `department_id`) | | [x] | GET | `/users` | view | Paginated list (`search`, `status`, `role_id`, `department_id`) |
| [x] | GET | `/users/:id` | view | User detail | | [x] | GET | `/users/:id` | view | User detail |
| [x] | POST | `/users` | create | Create user (bcrypt password, mobile encrypted) | | [x] | POST | `/users` | create | Create user with `role_ids[]` (bcrypt password, mobile encrypted) |
| [x] | PUT | `/users/:id` | edit | Update user | | [x] | PUT | `/users/:id` | edit | Update user; optional `role_ids[]` replaces assigned roles |
| [x] | DELETE | `/users/:id` | delete | Soft delete | | [x] | DELETE | `/users/:id` | delete | Soft delete |
--- ---

View File

@ -739,6 +739,19 @@ model role_permissions {
@@unique([role_id, permission_id]) @@unique([role_id, permission_id])
} }
model user_roles {
id BigInt @id @default(autoincrement())
user_id BigInt
role_id BigInt
created_at DateTime @default(now()) @db.Timestamptz(6)
roles roles @relation(fields: [role_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([user_id, role_id])
@@index([user_id], map: "idx_user_roles_user_id")
@@index([role_id], map: "idx_user_roles_role_id")
}
model roles { model roles {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
name String @unique @db.VarChar(100) name String @unique @db.VarChar(100)
@ -751,9 +764,9 @@ model roles {
deleted_at DateTime? @db.Timestamptz(6) deleted_at DateTime? @db.Timestamptz(6)
po_approvals po_approvals[] po_approvals po_approvals[]
role_permissions role_permissions[] role_permissions role_permissions[]
user_roles user_roles[]
users_roles_created_byTousers users? @relation("roles_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction, map: "fk_roles_created_by") users_roles_created_byTousers users? @relation("roles_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction, map: "fk_roles_created_by")
users_roles_updated_byTousers users? @relation("roles_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction, map: "fk_roles_updated_by") users_roles_updated_byTousers users? @relation("roles_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction, map: "fk_roles_updated_by")
users_users_role_idToroles users[] @relation("users_role_idToroles")
} }
model uom { model uom {
@ -782,7 +795,6 @@ model users {
mobile String? @db.VarChar(255) mobile String? @db.VarChar(255)
mobile_index String? @db.VarChar(64) mobile_index String? @db.VarChar(64)
password_hash String @db.VarChar(255) password_hash String @db.VarChar(255)
role_id BigInt?
department_id BigInt? department_id BigInt?
designation_id BigInt? designation_id BigInt?
plant_id BigInt? plant_id BigInt?
@ -841,6 +853,7 @@ model users {
purchase_orders_purchase_orders_created_byTousers purchase_orders[] @relation("purchase_orders_created_byTousers") purchase_orders_purchase_orders_created_byTousers purchase_orders[] @relation("purchase_orders_created_byTousers")
purchase_orders_purchase_orders_updated_byTousers purchase_orders[] @relation("purchase_orders_updated_byTousers") purchase_orders_purchase_orders_updated_byTousers purchase_orders[] @relation("purchase_orders_updated_byTousers")
refresh_tokens refresh_tokens[] refresh_tokens refresh_tokens[]
user_roles user_roles[]
roles_roles_created_byTousers roles[] @relation("roles_created_byTousers") roles_roles_created_byTousers roles[] @relation("roles_created_byTousers")
roles_roles_updated_byTousers roles[] @relation("roles_updated_byTousers") roles_roles_updated_byTousers roles[] @relation("roles_updated_byTousers")
uom_uom_created_byTousers uom[] @relation("uom_created_byTousers") uom_uom_created_byTousers uom[] @relation("uom_created_byTousers")
@ -852,7 +865,6 @@ model users {
plants_users_plant_idToplants plants? @relation("users_plant_idToplants", fields: [plant_id], references: [id], onUpdate: NoAction) plants_users_plant_idToplants plants? @relation("users_plant_idToplants", fields: [plant_id], references: [id], onUpdate: NoAction)
users_users_reporting_toTousers users? @relation("users_reporting_toTousers", fields: [reporting_to], references: [id], onUpdate: NoAction) users_users_reporting_toTousers users? @relation("users_reporting_toTousers", fields: [reporting_to], references: [id], onUpdate: NoAction)
other_users_users_reporting_toTousers users[] @relation("users_reporting_toTousers") other_users_users_reporting_toTousers users[] @relation("users_reporting_toTousers")
roles_users_role_idToroles roles? @relation("users_role_idToroles", fields: [role_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
users_users_updated_byTousers users? @relation("users_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) users_users_updated_byTousers users? @relation("users_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
other_users_users_updated_byTousers users[] @relation("users_updated_byTousers") other_users_users_updated_byTousers users[] @relation("users_updated_byTousers")
vendor_addresses_vendor_addresses_created_byTousers vendor_addresses[] @relation("vendor_addresses_created_byTousers") vendor_addresses_vendor_addresses_created_byTousers vendor_addresses[] @relation("vendor_addresses_created_byTousers")
@ -867,8 +879,6 @@ model users {
vendors_vendors_updated_byTousers vendors[] @relation("vendors_updated_byTousers") vendors_vendors_updated_byTousers vendors[] @relation("vendors_updated_byTousers")
warehouses_warehouses_created_byTousers warehouses[] @relation("warehouses_created_byTousers") warehouses_warehouses_created_byTousers warehouses[] @relation("warehouses_created_byTousers")
warehouses_warehouses_updated_byTousers warehouses[] @relation("warehouses_updated_byTousers") warehouses_warehouses_updated_byTousers warehouses[] @relation("warehouses_updated_byTousers")
@@index([role_id], map: "idx_users_role_id")
} }
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.

View File

@ -15,10 +15,9 @@ async function main() {
const passwordHash = await bcrypt.hash('Admin@123', 12); const passwordHash = await bcrypt.hash('Admin@123', 12);
await prisma.users.upsert({ const user = await prisma.users.upsert({
where: { email: 'admin@bharaterp.com' }, where: { email: 'admin@bharaterp.com' },
update: { update: {
role_id: superAdminRole.id,
status: 'active', status: 'active',
is_active: true, is_active: true,
deleted_at: null, deleted_at: null,
@ -28,12 +27,25 @@ async function main() {
full_name: 'Super Admin', full_name: 'Super Admin',
email: 'admin@bharaterp.com', email: 'admin@bharaterp.com',
password_hash: passwordHash, password_hash: passwordHash,
role_id: superAdminRole.id,
status: 'active', status: 'active',
is_active: true, is_active: true,
}, },
}); });
await prisma.user_roles.upsert({
where: {
user_id_role_id: {
user_id: user.id,
role_id: superAdminRole.id,
},
},
update: {},
create: {
user_id: user.id,
role_id: superAdminRole.id,
},
});
console.log('Bootstrap Super Admin ready: admin@bharaterp.com / Admin@123'); console.log('Bootstrap Super Admin ready: admin@bharaterp.com / Admin@123');
} }

View File

@ -0,0 +1,19 @@
-- Multi-role support: user_roles junction table + backfill from users.role_id
CREATE TABLE IF NOT EXISTS user_roles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id);
INSERT INTO user_roles (user_id, role_id)
SELECT id, role_id
FROM users
WHERE role_id IS NOT NULL
AND deleted_at IS NULL
ON CONFLICT (user_id, role_id) DO NOTHING;

View File

@ -0,0 +1,10 @@
-- Remove legacy users.role_id after roles are stored in user_roles
INSERT INTO user_roles (user_id, role_id)
SELECT id, role_id
FROM users
WHERE role_id IS NOT NULL
AND deleted_at IS NULL
ON CONFLICT (user_id, role_id) DO NOTHING;
ALTER TABLE users DROP COLUMN IF EXISTS role_id;

View File

@ -324,14 +324,18 @@ components:
UsersCreateBody: UsersCreateBody:
type: object type: object
required: [employee_code, full_name, email, password, role_id] required: [employee_code, full_name, email, password, role_ids]
properties: properties:
employee_code: { type: string, example: 'EMP002' } employee_code: { type: string, example: 'EMP002' }
full_name: { type: string, example: 'Jane Doe' } full_name: { type: string, example: 'Jane Doe' }
email: { type: string, format: email, example: 'jane@bharaterp.com' } email: { type: string, format: email, example: 'jane@bharaterp.com' }
password: { type: string, minLength: 8, example: 'Admin@123' } password: { type: string, minLength: 8, example: 'Admin@123' }
mobile: { type: string, example: '9876543210' } mobile: { type: string, example: '9876543210' }
role_id: { type: integer, example: 2 } role_ids:
type: array
minItems: 1
items: { type: integer }
example: [2, 5]
department_id: { type: integer, example: 1 } department_id: { type: integer, example: 1 }
designation_id: { type: integer, example: 1 } designation_id: { type: integer, example: 1 }
plant_id: { type: integer, example: 1 } plant_id: { type: integer, example: 1 }
@ -347,7 +351,11 @@ components:
email: { type: string, format: email, example: 'jane@bharaterp.com' } email: { type: string, format: email, example: 'jane@bharaterp.com' }
password: { type: string, minLength: 8, example: 'NewPass@123' } password: { type: string, minLength: 8, example: 'NewPass@123' }
mobile: { type: string, example: '9876543210' } mobile: { type: string, example: '9876543210' }
role_id: { type: integer, example: 2 } role_ids:
type: array
minItems: 1
items: { type: integer }
example: [2, 5]
department_id: { type: integer, example: 1 } department_id: { type: integer, example: 1 }
designation_id: { type: integer, example: 1 } designation_id: { type: integer, example: 1 }
plant_id: { type: integer, example: 1 } plant_id: { type: integer, example: 1 }

View File

@ -2,6 +2,19 @@ const jwt = require('jsonwebtoken');
const ApiError = require('../utils/ApiError'); const ApiError = require('../utils/ApiError');
const env = require('../config/env'); const env = require('../config/env');
const prisma = require('../config/prisma'); const prisma = require('../config/prisma');
const { extractUserRoles } = require('../utils/userPermissions');
const rolePermissionsInclude = {
user_roles: {
include: {
roles: {
include: {
role_permissions: { include: { permissions: { include: { modules: true } } } },
},
},
},
},
};
module.exports = async (req, _res, next) => { module.exports = async (req, _res, next) => {
try { try {
@ -15,20 +28,16 @@ module.exports = async (req, _res, next) => {
const user = await prisma.users.findFirst({ const user = await prisma.users.findFirst({
where: { id: BigInt(payload.sub), deleted_at: null }, where: { id: BigInt(payload.sub), deleted_at: null },
include: { include: rolePermissionsInclude,
roles_users_role_idToroles: {
include: {
role_permissions: { include: { permissions: { include: { modules: true } } } },
},
},
},
}); });
if (!user || !user.is_active || user.status !== 'active') { if (!user || !user.is_active || user.status !== 'active') {
throw new ApiError(401, 'Invalid or inactive user'); throw new ApiError(401, 'Invalid or inactive user');
} }
user.role = user.roles_users_role_idToroles; const roles = extractUserRoles(user);
user.roles = roles;
user.role = roles[0] || null;
req.user = user; req.user = user;
return next(); return next();
} catch (err) { } catch (err) {

View File

@ -1,9 +1,12 @@
const ApiError = require('../utils/ApiError'); const ApiError = require('../utils/ApiError');
const authorize = (moduleCode, action) => (req, _res, next) => { const authorize = (moduleCode, action) => (req, _res, next) => {
const permissions = req.user?.role?.role_permissions || []; const roles = req.user?.roles || (req.user?.role ? [req.user.role] : []);
const allowed = permissions.some( const allowed = roles.some((role) =>
(rp) => rp.permissions.modules.code === moduleCode && rp.permissions.action === action (role.role_permissions || []).some(
(rp) =>
rp.permissions?.modules?.code === moduleCode && rp.permissions?.action === action
)
); );
if (!allowed) return next(new ApiError(403, `Forbidden: requires ${moduleCode}:${action}`)); if (!allowed) return next(new ApiError(403, `Forbidden: requires ${moduleCode}:${action}`));

View File

@ -5,15 +5,14 @@ const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError'); const ApiError = require('../../utils/ApiError');
const env = require('../../config/env'); const env = require('../../config/env');
const { decrypt } = require('../../utils/encryption'); const { decrypt } = require('../../utils/encryption');
const { collectUserPermissions, extractUserRoles } = require('../../utils/userPermissions');
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex'); const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
const issueTokens = async (user) => { const issueTokens = async (user) => {
const accessToken = jwt.sign( const accessToken = jwt.sign({ sub: user.id.toString() }, env.JWT_ACCESS_SECRET, {
{ sub: user.id.toString(), role_id: user.role_id?.toString() }, expiresIn: env.JWT_ACCESS_EXPIRY,
env.JWT_ACCESS_SECRET, });
{ expiresIn: env.JWT_ACCESS_EXPIRY }
);
const refreshToken = crypto.randomBytes(40).toString('hex'); const refreshToken = crypto.randomBytes(40).toString('hex');
await prisma.refresh_tokens.create({ await prisma.refresh_tokens.create({
@ -92,29 +91,19 @@ const logout = async (refreshToken) => {
}); });
}; };
const buildPermissionList = (rolePermissions = []) => {
const codes = rolePermissions
.filter(
(rp) =>
rp.permissions?.is_active &&
rp.permissions?.modules?.is_active &&
rp.permissions?.modules?.code &&
rp.permissions?.action
)
.map((rp) => `${rp.permissions.modules.code}:${rp.permissions.action}`);
return [...new Set(codes)];
};
const getMe = async (userId) => { const getMe = async (userId) => {
const user = await prisma.users.findFirst({ const user = await prisma.users.findFirst({
where: { id: BigInt(userId), deleted_at: null }, where: { id: BigInt(userId), deleted_at: null },
include: { include: {
roles_users_role_idToroles: { user_roles: {
include: { include: {
role_permissions: { roles: {
include: { include: {
permissions: { include: { modules: true } }, role_permissions: {
include: {
permissions: { include: { modules: true } },
},
},
}, },
}, },
}, },
@ -129,8 +118,12 @@ const getMe = async (userId) => {
throw new ApiError(401, 'Invalid or inactive user'); throw new ApiError(401, 'Invalid or inactive user');
} }
const role = user.roles_users_role_idToroles; const roles = extractUserRoles(user).map((role) => ({
const permissions = buildPermissionList(role?.role_permissions); id: role.id,
name: role.name,
description: role.description,
}));
const permissions = collectUserPermissions(user);
return { return {
id: user.id, id: user.id,
@ -141,7 +134,8 @@ const getMe = async (userId) => {
status: user.status, status: user.status,
is_active: user.is_active, is_active: user.is_active,
last_login_at: user.last_login_at, last_login_at: user.last_login_at,
role: role ? { id: role.id, name: role.name, description: role.description } : null, roles,
role: roles[0] || null,
department: user.departments, department: user.departments,
designation: user.designations, designation: user.designations,
plant: user.plants_users_plant_idToplants, plant: user.plants_users_plant_idToplants,

View File

@ -157,7 +157,7 @@ const listRoles = async (query) => {
where, where,
include: { include: {
role_permissions: { include: permissionSelect }, role_permissions: { include: permissionSelect },
_count: { select: { users_users_role_idToroles: true } }, _count: { select: { user_roles: true } },
}, },
orderBy: { created_at: 'desc' }, orderBy: { created_at: 'desc' },
skip, skip,
@ -168,7 +168,7 @@ const listRoles = async (query) => {
const data = rows.map((role) => ({ const data = rows.map((role) => ({
...sanitizeRole(role), ...sanitizeRole(role),
user_count: role._count.users_users_role_idToroles, user_count: role._count.user_roles,
})); }));
return { data, meta: { page, limit, total } }; return { data, meta: { page, limit, total } };
@ -179,11 +179,11 @@ const getRoleById = async (id) => {
where: { id: BigInt(id), deleted_at: null }, where: { id: BigInt(id), deleted_at: null },
include: { include: {
role_permissions: { include: permissionSelect }, role_permissions: { include: permissionSelect },
_count: { select: { users_users_role_idToroles: true } }, _count: { select: { user_roles: true } },
}, },
}); });
if (!role) throw new ApiError(404, 'Role not found'); if (!role) throw new ApiError(404, 'Role not found');
return { ...sanitizeRole(role), user_count: role._count.users_users_role_idToroles }; return { ...sanitizeRole(role), user_count: role._count.user_roles };
}; };
const updateRole = async (id, payload, userId, requestId) => { const updateRole = async (id, payload, userId, requestId) => {
@ -233,8 +233,8 @@ const deleteRole = async (id, userId, requestId) => {
}); });
if (!existing) throw new ApiError(404, 'Role not found'); if (!existing) throw new ApiError(404, 'Role not found');
const assignedUsers = await prisma.users.count({ const assignedUsers = await prisma.user_roles.count({
where: { role_id: BigInt(id), deleted_at: null }, where: { role_id: BigInt(id), users: { deleted_at: null } },
}); });
if (assignedUsers > 0) { if (assignedUsers > 0) {
throw new ApiError(400, 'Cannot delete role assigned to active users'); throw new ApiError(400, 'Cannot delete role assigned to active users');
@ -322,7 +322,7 @@ const listRoleCards = async () => {
where: { deleted_at: null }, where: { deleted_at: null },
include: { include: {
role_permissions: { include: permissionSelect }, role_permissions: { include: permissionSelect },
_count: { select: { users_users_role_idToroles: true } }, _count: { select: { user_roles: true } },
}, },
orderBy: { created_at: 'asc' }, orderBy: { created_at: 'asc' },
}); });
@ -332,7 +332,7 @@ const listRoleCards = async () => {
name: role.name, name: role.name,
description: role.description, description: role.description,
is_active: role.is_active, is_active: role.is_active,
user_count: role._count.users_users_role_idToroles, user_count: role._count.user_roles,
permission_count: role.role_permissions.length, permission_count: role.role_permissions.length,
})); }));
}; };

View File

@ -5,19 +5,27 @@ const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog'); const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination'); const { getPagination } = require('../../utils/pagination');
const { encrypt, decrypt, blindIndex } = require('../../utils/encryption'); const { encrypt, decrypt, blindIndex } = require('../../utils/encryption');
const { extractUserRoles } = require('../../utils/userPermissions');
const userInclude = { const userInclude = {
roles_users_role_idToroles: { select: { id: true, name: true } }, user_roles: {
include: { roles: { select: { id: true, name: true } } },
},
departments: { select: { id: true, name: true } }, departments: { select: { id: true, name: true } },
designations: { select: { id: true, name: true } }, designations: { select: { id: true, name: true } },
plants_users_plant_idToplants: { select: { id: true, code: true, name: true } }, plants_users_plant_idToplants: { select: { id: true, code: true, name: true } },
users_users_reporting_toTousers: { select: { id: true, full_name: true, employee_code: true } }, users_users_reporting_toTousers: { select: { id: true, full_name: true, employee_code: true } },
}; };
const mapUserRoles = (user) =>
extractUserRoles(user)
.map((role) => ({ id: role.id, name: role.name }))
.sort((a, b) => a.name.localeCompare(b.name));
const sanitizeUser = (user) => { const sanitizeUser = (user) => {
if (!user) return null; if (!user) return null;
const { const {
roles_users_role_idToroles, user_roles,
plants_users_plant_idToplants, plants_users_plant_idToplants,
users_users_reporting_toTousers, users_users_reporting_toTousers,
departments, departments,
@ -27,10 +35,13 @@ const sanitizeUser = (user) => {
delete rest.password_hash; delete rest.password_hash;
delete rest.mobile_index; delete rest.mobile_index;
const roles = mapUserRoles(user);
return { return {
...rest, ...rest,
mobile: rest.mobile ? decrypt(rest.mobile) : null, mobile: rest.mobile ? decrypt(rest.mobile) : null,
role: roles_users_role_idToroles || null, roles,
role: roles[0] || null,
department: departments || null, department: departments || null,
designation: designations || null, designation: designations || null,
plant: plants_users_plant_idToplants || null, plant: plants_users_plant_idToplants || null,
@ -42,7 +53,13 @@ const buildUsersWhere = (query) => ({
deleted_at: null, deleted_at: null,
...(query.status ? { status: query.status } : {}), ...(query.status ? { status: query.status } : {}),
...(query.is_active !== undefined ? { is_active: query.is_active } : {}), ...(query.is_active !== undefined ? { is_active: query.is_active } : {}),
...(query.role_id ? { role_id: BigInt(query.role_id) } : {}), ...(query.role_id
? {
user_roles: {
some: { role_id: BigInt(query.role_id) },
},
}
: {}),
...(query.department_id ? { department_id: BigInt(query.department_id) } : {}), ...(query.department_id ? { department_id: BigInt(query.department_id) } : {}),
...(query.search ...(query.search
? { ? {
@ -73,6 +90,7 @@ const toUserListItem = (user) => {
email: row.email, email: row.email,
initials: toInitials(row.full_name), initials: toInitials(row.full_name),
employee_code: row.employee_code, employee_code: row.employee_code,
roles: row.roles,
role: row.role, role: row.role,
department: row.department, department: row.department,
plant: row.plant, plant: row.plant,
@ -82,13 +100,22 @@ const toUserListItem = (user) => {
}; };
}; };
const assertFk = async (payload) => { const assertRoleIds = async (roleIds) => {
if (payload.role_id) { const uniqueIds = [...new Set((roleIds || []).map((id) => BigInt(id)))];
const role = await prisma.roles.findFirst({ if (uniqueIds.length === 0) throw new ApiError(422, 'At least one role is required');
where: { id: BigInt(payload.role_id), deleted_at: null, is_active: true },
}); const roles = await prisma.roles.findMany({
if (!role) throw new ApiError(422, 'Invalid role_id'); where: { id: { in: uniqueIds }, deleted_at: null, is_active: true },
});
if (roles.length !== uniqueIds.length) {
throw new ApiError(422, 'Invalid role_ids');
} }
return uniqueIds;
};
const assertFk = async (payload) => {
if (payload.department_id) { if (payload.department_id) {
const row = await prisma.departments.findFirst({ const row = await prisma.departments.findFirst({
where: { id: BigInt(payload.department_id), deleted_at: null }, where: { id: BigInt(payload.department_id), deleted_at: null },
@ -117,6 +144,7 @@ const assertFk = async (payload) => {
const buildUserData = async (payload, { hashPassword = false } = {}) => { const buildUserData = async (payload, { hashPassword = false } = {}) => {
const data = { ...payload }; const data = { ...payload };
delete data.role_ids;
if (hashPassword && data.password) { if (hashPassword && data.password) {
data.password_hash = await bcrypt.hash(data.password, env.BCRYPT_SALT_ROUNDS); data.password_hash = await bcrypt.hash(data.password, env.BCRYPT_SALT_ROUNDS);
@ -135,15 +163,27 @@ const buildUserData = async (payload, { hashPassword = false } = {}) => {
} }
} }
for (const key of ['role_id', 'department_id', 'designation_id', 'plant_id', 'reporting_to']) { for (const key of ['department_id', 'designation_id', 'plant_id', 'reporting_to']) {
if (data[key] !== undefined && data[key] !== null) data[key] = BigInt(data[key]); if (data[key] !== undefined && data[key] !== null) data[key] = BigInt(data[key]);
} }
return data; return data;
}; };
const syncUserRoles = async (tx, userId, roleIds) => {
const uniqueRoleIds = await assertRoleIds(roleIds);
await tx.user_roles.deleteMany({ where: { user_id: userId } });
await tx.user_roles.createMany({
data: uniqueRoleIds.map((role_id) => ({ user_id: userId, role_id })),
});
return uniqueRoleIds;
};
const createUser = async (payload, userId, requestId) => { const createUser = async (payload, userId, requestId) => {
await assertFk(payload); await assertFk(payload);
const roleIds = await assertRoleIds(payload.role_ids);
const existing = await prisma.users.findFirst({ const existing = await prisma.users.findFirst({
where: { where: {
@ -157,19 +197,41 @@ const createUser = async (payload, userId, requestId) => {
data.created_by = userId ? BigInt(userId) : null; data.created_by = userId ? BigInt(userId) : null;
data.updated_by = userId ? BigInt(userId) : null; data.updated_by = userId ? BigInt(userId) : null;
const created = await prisma.users.create({ data, include: userInclude }); const created = await prisma.$transaction(async (tx) => {
const user = await tx.users.create({ data });
await tx.user_roles.createMany({
data: roleIds.map((role_id) => ({ user_id: user.id, role_id })),
});
return tx.users.findFirst({
where: { id: user.id },
include: userInclude,
});
});
const sanitized = sanitizeUser(created);
await auditLog({ await auditLog({
tableName: 'users', tableName: 'users',
recordId: created.id, recordId: created.id,
action: 'CREATE', action: 'CREATE',
oldValue: null, oldValue: null,
newValue: sanitizeUser(created), newValue: sanitized,
userId, userId,
requestId, requestId,
}); });
return sanitizeUser(created); await auditLog({
tableName: 'user_roles',
recordId: created.id,
action: 'CREATE',
oldValue: null,
newValue: roleIds.map((id) => id.toString()),
userId,
requestId,
});
return sanitized;
}; };
const listUsers = async (query) => { const listUsers = async (query) => {
@ -251,7 +313,7 @@ const exportUsers = async (query) => {
'Full Name', 'Full Name',
'Email', 'Email',
'Employee Code', 'Employee Code',
'Role', 'Roles',
'Department', 'Department',
'Plant', 'Plant',
'Last Login', 'Last Login',
@ -264,7 +326,7 @@ const exportUsers = async (query) => {
item.full_name, item.full_name,
item.email, item.email,
item.employee_code, item.employee_code,
item.role?.name || '', item.roles.map((role) => role.name).join('; '),
item.department?.name || '', item.department?.name || '',
item.plant?.name || '', item.plant?.name || '',
item.last_login_at ? new Date(item.last_login_at).toISOString() : '', item.last_login_at ? new Date(item.last_login_at).toISOString() : '',
@ -312,23 +374,47 @@ const updateUser = async (id, payload, userId, requestId) => {
const data = await buildUserData(payload, { hashPassword: Boolean(payload.password) }); const data = await buildUserData(payload, { hashPassword: Boolean(payload.password) });
data.updated_by = userId ? BigInt(userId) : null; data.updated_by = userId ? BigInt(userId) : null;
const updated = await prisma.users.update({ const userIdBigInt = BigInt(id);
where: { id: BigInt(id) }, const previousRoleIds = (existing.user_roles || []).map((entry) => entry.role_id.toString());
data,
include: userInclude, const updated = await prisma.$transaction(async (tx) => {
if (payload.role_ids) {
await syncUserRoles(tx, userIdBigInt, payload.role_ids);
}
return tx.users.update({
where: { id: userIdBigInt },
data,
include: userInclude,
});
}); });
const sanitized = sanitizeUser(updated);
await auditLog({ await auditLog({
tableName: 'users', tableName: 'users',
recordId: id, recordId: id,
action: 'UPDATE', action: 'UPDATE',
oldValue: sanitizeUser(existing), oldValue: sanitizeUser(existing),
newValue: sanitizeUser(updated), newValue: sanitized,
userId, userId,
requestId, requestId,
}); });
return sanitizeUser(updated); if (payload.role_ids) {
const newRoleIds = sanitized.roles.map((role) => role.id.toString());
await auditLog({
tableName: 'user_roles',
recordId: id,
action: 'UPDATE',
oldValue: previousRoleIds,
newValue: newRoleIds,
userId,
requestId,
});
}
return sanitized;
}; };
const deleteUser = async (id, userId, requestId) => { const deleteUser = async (id, userId, requestId) => {

View File

@ -1,12 +1,14 @@
const Joi = require('joi'); const Joi = require('joi');
const roleIdsSchema = Joi.array().items(Joi.number().integer().positive()).min(1);
const createUserSchema = Joi.object({ const createUserSchema = Joi.object({
employee_code: Joi.string().max(30).required(), employee_code: Joi.string().max(30).required(),
full_name: Joi.string().max(200).required(), full_name: Joi.string().max(200).required(),
email: Joi.string().email().max(200).required(), email: Joi.string().email().max(200).required(),
password: Joi.string().min(8).required(), password: Joi.string().min(8).required(),
mobile: Joi.string().max(20).allow(null, '').optional(), mobile: Joi.string().max(20).allow(null, '').optional(),
role_id: Joi.number().integer().positive().required(), role_ids: roleIdsSchema.required(),
department_id: Joi.number().integer().positive().allow(null).optional(), department_id: Joi.number().integer().positive().allow(null).optional(),
designation_id: Joi.number().integer().positive().allow(null).optional(), designation_id: Joi.number().integer().positive().allow(null).optional(),
plant_id: Joi.number().integer().positive().allow(null).optional(), plant_id: Joi.number().integer().positive().allow(null).optional(),
@ -21,7 +23,7 @@ const updateUserSchema = Joi.object({
email: Joi.string().email().max(200).optional(), email: Joi.string().email().max(200).optional(),
password: Joi.string().min(8).optional(), password: Joi.string().min(8).optional(),
mobile: Joi.string().max(20).allow(null, '').optional(), mobile: Joi.string().max(20).allow(null, '').optional(),
role_id: Joi.number().integer().positive().optional(), role_ids: roleIdsSchema.optional(),
department_id: Joi.number().integer().positive().allow(null).optional(), department_id: Joi.number().integer().positive().allow(null).optional(),
designation_id: Joi.number().integer().positive().allow(null).optional(), designation_id: Joi.number().integer().positive().allow(null).optional(),
plant_id: Joi.number().integer().positive().allow(null).optional(), plant_id: Joi.number().integer().positive().allow(null).optional(),

View File

@ -0,0 +1,30 @@
const buildPermissionList = (rolePermissions = []) => {
const codes = rolePermissions
.filter(
(rp) =>
rp.permissions?.is_active &&
rp.permissions?.modules?.is_active &&
rp.permissions?.modules?.code &&
rp.permissions?.action
)
.map((rp) => `${rp.permissions.modules.code}:${rp.permissions.action}`);
return [...new Set(codes)];
};
const extractUserRoles = (user) => {
if (!user?.user_roles?.length) return [];
return user.user_roles.map((entry) => entry.roles).filter(Boolean);
};
const collectUserPermissions = (user) => {
const roles = extractUserRoles(user);
const rolePermissions = roles.flatMap((role) => role.role_permissions || []);
return buildPermissionList(rolePermissions);
};
module.exports = {
buildPermissionList,
extractUserRoles,
collectUserPermissions,
};