diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index 721dce9..98b3661 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -21,18 +21,22 @@ ***********************************************************************************************************************************************************************/ const sequelize = require('../../config/db.config') const { Op, Sequelize } = require('sequelize') +const bcrypt = require('bcryptjs') +const crypto = require('crypto'); + const mdl_Users = require('../../models/users/users.mdl'); const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); +const sendEmail = require('../../services/email.service'); const R = require('../../utils/response.util'); const { paginate, auditInclude } = require('../../utils/paginate.util'); const { enrichPersonalInfo } = require('../../utils/personalInfo.util'); -const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); -const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas } = require('../../models/users/user_groups.attributes'); +const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: usersComputed } = require('../../models/users/users.attributes'); +const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes'); -const EXCLUDED = ['password', 'otp_code', 'otp_expires_at']; +const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at']; const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; exports.getUsers = async (req, res) => { @@ -70,6 +74,64 @@ exports.getUser = async (req, res) => { } }; +// ─── POST add staff user ─────────────────────────────────────────────────────── +exports.addStaffUser = async (req, res) => { + try { + const { email, personal_info = {} } = req.body; // ← password removed from body + + if (!email) + return R.error(res, 'Email is required.', 400); + + if (!personal_info?.name?.given_name || !personal_info?.name?.last_name) + return R.error(res, 'First name and last name are required.', 400); + + const existing = await mdl_Users.findOne({ where: { email } }); + if (existing) + return R.error(res, 'Email is already in use.', 409); + + // ── Auto-generate a temporary password ──────────────────────────────────── + const plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12); + const hashed = await bcrypt.hash(plainPassword, 12); + + // ── Set password expiry (24 hours from now) ─────────────────────────────── + const passwordExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const enriched = enrichPersonalInfo(personal_info); + const fullName = enriched?.name?.full_name ?? email; + + const user = await mdl_Users.create({ + email, + password: hashed, + password_expires_at: passwordExpiresAt, // ← requires column in DB + must_change_password: true, // ← force change on first login + acc_type: 'staff', + reg_type: 'system', + is_active: true, + is_verified: true, + createdBy: req.user.user_id, + personal_info: enriched, + }); + + await sendEmail({ + to: email, type: "ADD_STAFF", data: { + name: fullName, + email, + password: plainPassword, + expiryHours: 24, + } + }); + + return R.success(res, 'Staff user created successfully.', { + user_id: user.user_id, + email: user.email, + acc_type: user.acc_type, + }, 201); + } catch (err) { + console.error('[ADMIN][ADD STAFF USER]', err); + return R.error(res, 'Could not create staff user.', 500); + } +}; + // ─── PUT update any user ─────────────────────────────────────────────────────── exports.updateUser = async (req, res) => { try { @@ -259,21 +321,8 @@ exports.getGroups = async (req, res) => { const result = await paginate(mdl_UserGroups, req, { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, + computedAttributes: groupComputed, // ← just pass it, paginate handles the rest auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, - findOptions: { - attributes: { - include: [ - [ - Sequelize.literal(`( - SELECT CAST(COUNT(*) AS INTEGER) - FROM "user_group_members" - WHERE "user_group_members"."group_id" = "UserGroup"."group_id" - )`), - 'memberCount', - ], - ], - }, - }, }); return R.success(res, 'Groups retrieved.', result); @@ -289,19 +338,16 @@ exports.getGroup = async (req, res) => { if (!group) return R.error(res, 'Group not found.', 404); const members = await paginate(mdl_Users, req, { - excludeAttributes: usersExclude, - jsonbSchemas: usersSchemas, - jsonbColumn: 'personal_info', - auditOptions: { mdl_Users, parentAlias: 'User' }, + excludeAttributes: groupExclude, + jsonbSchemas: groupSchemas, + computedAttributes: groupComputed, + auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, findOptions: { - include: [ - { - model: mdl_UserGroupMembers, - where: { group_id: req.params.gid }, - attributes: [], - required: true, - }, - ], + paranoid: false, + where: { + deletedAt: { [Op.ne]: null }, + is_active: false, + }, }, }); @@ -649,4 +695,175 @@ exports.getArchivedUsers = async (req, res) => { console.error('[ADMIN][GET ARCHIVED USERS]', err); return R.error(res, 'Could not retrieve archived users.', 500); } +}; + +// ─── BULK DEACTIVATE groups ─────────────────────────────────────────────────── +exports.bulkDeactivateGroups = async (req, res) => { + try { + const { ids } = req.body; + + if (!Array.isArray(ids) || ids.length === 0) + return R.error(res, 'No group IDs provided.', 400); + + const groups = await mdl_UserGroups.findAll({ + where: { group_id: ids }, + }); + + if (!groups.length) + return R.error(res, 'No groups found.', 404); + + const activeGroups = groups.filter((g) => g.is_active && !g.deletedAt); + + if (!activeGroups.length) + return R.error(res, 'All selected groups are already deactivated.', 400); + + const activeIds = activeGroups.map((g) => g.group_id); + + await mdl_UserGroups.update( + { is_active: false, deletedBy: req.user.user_id }, + { where: { group_id: activeIds } } + ); + + await mdl_UserGroups.destroy({ + where: { group_id: activeIds }, + }); + + return R.success(res, `${activeIds.length} group(s) deactivated successfully.`, { + deactivated_ids: activeIds, + skipped_ids: ids.filter((id) => !activeIds.includes(id)), + }); + } catch (err) { + console.error('[ADMIN][BULK DEACTIVATE GROUPS]', err); + return R.error(res, 'Could not deactivate groups.', 500); + } +}; + +// ─── BULK RESTORE groups ────────────────────────────────────────────────────── +exports.bulkRestoreGroups = async (req, res) => { + try { + const { ids } = req.body; + + if (!Array.isArray(ids) || ids.length === 0) + return R.error(res, 'No group IDs provided.', 400); + + const groups = await mdl_UserGroups.findAll({ + where: { group_id: ids }, + paranoid: false, + }); + + if (!groups.length) + return R.error(res, 'No groups found.', 404); + + const deletedGroups = groups.filter((g) => g.deletedAt); + + if (!deletedGroups.length) + return R.error(res, 'All selected groups are already active.', 400); + + const deletedIds = deletedGroups.map((g) => g.group_id); + + await mdl_UserGroups.restore({ where: { group_id: deletedIds } }); + + await mdl_UserGroups.update( + { is_active: true, updatedBy: req.user.user_id, deletedBy: null }, + { where: { group_id: deletedIds }, paranoid: false } + ); + + return R.success(res, `${deletedIds.length} group(s) restored successfully.`, { + restored_ids: deletedIds, + skipped_ids: ids.filter((id) => !deletedIds.includes(id)), + }); + } catch (err) { + console.error('[ADMIN][BULK RESTORE GROUPS]', err); + return R.error(res, 'Could not restore groups.', 500); + } +}; + +exports.getArchivedGroups = async (req, res) => { + try { + + const result = await paginate(mdl_UserGroups, req, { + excludeAttributes: groupExclude, + jsonbSchemas: groupSchemas, + computedAttributes: groupComputed, // ← just pass it, paginate handles the rest + auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, + findOptions: { + paranoid: false, + where: { + deletedAt: { [Op.ne]: null }, + is_active: false, + }, + }, + }); + + return R.success(res, 'Archived groups retrieved.', result); + } catch (err) { + console.error('[ADMIN][GET ARCHIVED GROUPS]', err); + return R.error(res, 'Could not retrieve archived groups.', 500); + } +}; + +// ─── GET group field values ─────────────────────────────────────────────────── +exports.getGroupFieldValues = async (req, res) => { + try { + const { field } = req.query; + if (!field) return R.error(res, 'Field is required.', 400); + + const allowedFields = Object.keys(mdl_UserGroups.rawAttributes).filter( + (f) => !['deletedAt'].includes(f) + ); + + const dateFields = ['createdAt', 'updatedAt', 'deletedAt']; + + if (!field.includes('.')) { + if (!allowedFields.includes(field)) + return R.error(res, 'Invalid or restricted field.', 400); + + // ─── Audit By fields — return names instead of IDs ─────────────────── + if (auditByFields.includes(field)) { + const [rows] = await sequelize.query(` + SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value + FROM user_groups g + JOIN users u ON u.user_id = g."${field}" + WHERE g."${field}" IS NOT NULL + AND u."personal_info"->'name'->>'full_name' IS NOT NULL + ORDER BY value ASC + `); + + const values = rows.map((r) => r.value).filter(Boolean); + return R.success(res, 'Field values retrieved.', values); + } + + // ─── Date fields ────────────────────────────────────────────────────── + if (dateFields.includes(field)) { + const results = await mdl_UserGroups.findAll({ + attributes: [ + [Sequelize.fn('DISTINCT', Sequelize.fn('DATE', Sequelize.col(field))), 'value'], + ], + where: { [field]: { [Op.ne]: null } }, + order: [[Sequelize.fn('DATE', Sequelize.col(field)), 'DESC']], + paranoid: false, + raw: true, + }); + + const values = results.map((r) => r.value).filter(Boolean); + return R.success(res, 'Field values retrieved.', values); + } + + // ─── Regular fields ─────────────────────────────────────────────────── + const results = await mdl_UserGroups.findAll({ + attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']], + where: { [field]: { [Op.ne]: null } }, + paranoid: false, + raw: true, + }); + + const values = results.map((r) => r.value).filter(Boolean).sort(); + return R.success(res, 'Field values retrieved.', values); + } + + return R.error(res, 'JSONB fields are not supported for groups.', 400); + } catch (err) { + console.error('[ADMIN][GET GROUP FIELD VALUES]', err); + return R.error(res, 'Could not retrieve field values.', 500); + } }; \ No newline at end of file diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index 0905bf2..ba5bbde 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -33,7 +33,7 @@ const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util') const sendEmail = require('../services/email.service'); const R = require('../utils/response.util'); -const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy']; +const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy']; // ─── Helpers ─────────────────────────────────────────────────────────────────── const buildLoginInfo = (req) => ({ @@ -264,6 +264,7 @@ exports.refreshToken = async (req, res) => { return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) }); } catch (err) { + console.error('[AUTH] refresh token error:', err); return R.error(res, 'Invalid or expired refresh token.', 401); } }; diff --git a/data/email_body.data.js b/data/email_body.data.js index 71c0b7b..68b08db 100644 --- a/data/email_body.data.js +++ b/data/email_body.data.js @@ -78,4 +78,39 @@ export const emailTemplates = {
Kindly ensure completion within the specified timeframe.
`, }), + + ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({ + subject: "Your Staff Account Has Been Created - STARR System", + title: "Staff Account Created", + body: ` +Dear ${name},
+ +Your staff account has been successfully created in the STARR System. + Below are your login credentials:
+ +| ${email} | +|
| Password | +${password} | +
⚠️ This temporary password is valid for ${expiryHours} hours. + You will be required to change it upon first login.
+ +If you did not request this account or believe this was created in error, + please contact your administrator immediately to have it deactivated.
+ ++ For security, please do not share your credentials with anyone. + If you need a new password, contact your administrator. +
+ `, + }), }; \ No newline at end of file diff --git a/models/users/user_groups.attributes.js b/models/users/user_groups.attributes.js index 5feb805..327e627 100644 --- a/models/users/user_groups.attributes.js +++ b/models/users/user_groups.attributes.js @@ -1,5 +1,5 @@ const excludeAttributes = [ - // Add here + "description" ]; const jsonbSchemas = { @@ -19,4 +19,19 @@ const userExclude = [ "deleted_at", ]; -module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas }; \ No newline at end of file +const computedAttributes = [ + { + key: "memberCount", + label: "Members", + type: "number", + order: 5, + literal: `( + SELECT CAST(COUNT(*) AS INTEGER) + FROM "user_group_members" + WHERE "user_group_members"."group_id" = "UserGroup"."group_id" + AND "user_group_members"."deletedAt" IS NULL + )`, + }, +]; + +module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas, computedAttributes }; \ No newline at end of file diff --git a/models/users/user_groups.mdl.js b/models/users/user_groups.mdl.js index 701814b..7abd849 100644 --- a/models/users/user_groups.mdl.js +++ b/models/users/user_groups.mdl.js @@ -16,10 +16,10 @@ const mdl_Users = require('./users.mdl'); // ─── UserGroups ──────────────────────────────────────────────────────────────── const mdl_UserGroups = sequelize.define('UserGroup', { - group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, - name: { type: DataTypes.STRING(50), allowNull: false }, - description: { type: DataTypes.TEXT }, - is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, + group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Group ID", order: 1, hidden: true }, + name: { type: DataTypes.STRING(50), allowNull: false, label: "Group Name", order: 2 }, + description: { type: DataTypes.TEXT, label: "Description", order: 3 }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 4 }, // ── Audit trails ──────────────────────────────────────────────────────────── createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, diff --git a/models/users/users.attributes.js b/models/users/users.attributes.js index 4592481..9a7d2eb 100644 --- a/models/users/users.attributes.js +++ b/models/users/users.attributes.js @@ -1,5 +1,5 @@ const excludeAttributes = [ - "password", "otp_code", "otp_expires_at", + "password", "otp_code", "otp_expires_at", 'must_change_password', 'password_expires_at', "personal_info.name.given_name", "personal_info.name.middle_name", "personal_info.name.last_name", @@ -46,4 +46,13 @@ const userExclude = [ "deleted_at", ]; -module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas }; \ No newline at end of file +const computedAttributes = [ + // { + // key: "memberCount", + // label: "Members", + // type: "number", + // order: 99, // adjust order as needed + // }, +]; + +module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas, computedAttributes }; \ No newline at end of file diff --git a/models/users/users.mdl.js b/models/users/users.mdl.js index 4825bd4..80dc1fd 100644 --- a/models/users/users.mdl.js +++ b/models/users/users.mdl.js @@ -42,6 +42,10 @@ const mdl_Users = sequelize.define('User', { */ personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 }, + // ── Password policy ───────────────────────────────────────────────────────── + must_change_password: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Must Change Password" }, + password_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Password Expires At" }, + // OTP fields (stored temporarily during verification flow) otp_code: { type: DataTypes.STRING(6), allowNull: true, label: "OTP Code" }, otp_expires_at: { type: DataTypes.DATE, allowNull: true, label: "OTP Expires At" }, diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index 71b2147..bfe1c1f 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -40,6 +40,7 @@ router.post('/users/bulk/restore', sensitiveOpsLimiter, usersCtrl.bulkRestoreUse router.delete('/users/bulk', sensitiveOpsLimiter, usersCtrl.bulkDeactivateUsers); // ← before /:id router.get('/users/field-values', usersCtrl.getUserFieldValues); // ← before /:id router.get('/users/archived', usersCtrl.getArchivedUsers); // ← before /:id +router.post('/users/staff', sensitiveOpsLimiter, usersCtrl.addStaffUser); router.get('/users', usersCtrl.getUsers); router.get('/users/:id', usersCtrl.getUser); router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser); @@ -51,6 +52,10 @@ router.get('/users/:id/sessions', usersCtrl.getUserSessions); router.delete('/users/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession); // ─── Groups Management ─────────────────────────────────────────────────────────────── +router.get('/groups/field-values', usersCtrl.getGroupFieldValues); // ← before /:gid +router.post('/groups/bulk/restore', sensitiveOpsLimiter, usersCtrl.bulkRestoreGroups); +router.delete('/groups/bulk', sensitiveOpsLimiter, usersCtrl.bulkDeactivateGroups); +router.get('/groups/archived', usersCtrl.getArchivedGroups); router.get('/groups', usersCtrl.getGroups); router.get('/groups/:gid', usersCtrl.getGroup); router.post('/groups', sensitiveOpsLimiter, usersCtrl.createGroup); diff --git a/utils/modelToAttributes.util.js b/utils/modelToAttributes.util.js index c2e1638..12dd1c8 100644 --- a/utils/modelToAttributes.util.js +++ b/utils/modelToAttributes.util.js @@ -147,26 +147,22 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa }); } - // ─── Sort all fields globally by order ───────────────────────────────────── - attributes.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)); - - // ─── Strip order from final output (frontend doesn't need it) ────────────── - const sorted = attributes.map(({ order: _, ...rest }) => rest); - // ── Audit fields in correct sequence ──────────────────────────────────────── - for (const { field, type } of auditSequence) { + for (const { field, type, order } of auditSequence) { if (exclude.includes(field)) continue; if (!rawAttrs[field]) continue; // skip if field doesn't exist on model - sorted.push({ + attributes.push({ name: defaultTimestampLabels[field], type, field, + order, options: resolveOptions(rawAttrs[field]?.type), }); } - return sorted; + // ── Sort by order — DO NOT strip order here, paginate.util does it ─────────── + return attributes.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)); } module.exports = { modelToAttributes }; \ No newline at end of file diff --git a/utils/paginate.util.js b/utils/paginate.util.js index e63f4ba..adab292 100644 --- a/utils/paginate.util.js +++ b/utils/paginate.util.js @@ -80,7 +80,8 @@ async function paginate(model, req, { jsonbSchemas = {}, jsonbColumn = null, findOptions = {}, - auditOptions = null, // ← { mdl_Users, parentAlias } + auditOptions = null, // ← { mdl_Users, parentAlias }, + computedAttributes = [] } = {}) { const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START); const limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT); @@ -104,7 +105,18 @@ async function paginate(model, req, { ? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes : []; const extraIncludes = findOptions.attributes?.include ?? []; - const mergedAttributeIncludes = [...baseIncludes, ...auditAttrs, ...extraIncludes]; + + // ← Convert { key, literal } objects into Sequelize [literal, alias] tuples + const computedIncludes = computedAttributes + .filter((c) => c.literal) + .map((c) => [Sequelize.literal(c.literal), c.key]); + + const mergedAttributeIncludes = [ + ...baseIncludes, + ...auditAttrs, + ...extraIncludes, + ...computedIncludes, // ← now proper tuples + ]; const { attributes: _attr, ...restFindOptions } = findOptions; @@ -122,12 +134,35 @@ async function paginate(model, req, { const data = rows .map((row) => row.toJSON?.() ?? row) - .map(auditIdToName); + .map(auditIdToName) + .map((row) => { + // ← Coerce computed types after toJSON + const result = { ...row }; + for (const { key, type } of computedAttributes) { + if (result[key] !== undefined && result[key] !== null) { + if (type === 'number') result[key] = parseInt(result[key], 10); + if (type === 'float') result[key] = parseFloat(result[key]); + } + } + return result; + }); - // ─── Flatten JSONB columns into dot-notation keys ────────────────────────── - const jsonbCols = jsonbColumn ? [jsonbColumn] : []; const totalPages = Math.ceil(count / limit); + // ← Append computed metadata + const computedMeta = computedAttributes.map(({ key, label, type, order: ord }) => ({ + name: label ?? key, + type: type ?? 'text', + field: key, + order: ord ?? Infinity, + options: {}, + })); + + // ← Merge, sort, THEN strip order + const mergedAttributes = [...attributes, ...computedMeta] + .sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)) + .map(({ order: _, ...rest }) => rest); + return { data, pagination: { @@ -138,7 +173,7 @@ async function paginate(model, req, { hasPrevPage: page > PAGE_START, hasNextPage: page < totalPages, }, - attributes, + attributes: mergedAttributes, }; }