mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -0,0 +1,350 @@
|
|||||||
|
// controllers/admin/assets.controller.js
|
||||||
|
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { Op } = require("sequelize");
|
||||||
|
const Asset = require("../../models/assets/assets.mdl");
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const notDeleted = { deletedAt: null };
|
||||||
|
|
||||||
|
function resolveFileType(mimeType = "") {
|
||||||
|
if (mimeType.startsWith("image/")) return "image";
|
||||||
|
if (mimeType.startsWith("video/")) return "video";
|
||||||
|
if (mimeType.startsWith("application/") || mimeType.startsWith("text/"))
|
||||||
|
return "document";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExtension(originalName = "") {
|
||||||
|
return path.extname(originalName).replace(".", "").toLowerCase() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChecksum(buffer) {
|
||||||
|
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResolution(width, height) {
|
||||||
|
if (!width || !height) return null;
|
||||||
|
const h = Math.min(width, height);
|
||||||
|
if (h >= 2160) return "4K";
|
||||||
|
if (h >= 1440) return "1440p";
|
||||||
|
if (h >= 1080) return "1080p";
|
||||||
|
if (h >= 720) return "720p";
|
||||||
|
if (h >= 480) return "480p";
|
||||||
|
if (h >= 360) return "360p";
|
||||||
|
if (h >= 240) return "240p";
|
||||||
|
return `${width}x${height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getAssets = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
file_type,
|
||||||
|
owner_type,
|
||||||
|
owner_id,
|
||||||
|
uploadedBy,
|
||||||
|
is_public,
|
||||||
|
resolution,
|
||||||
|
search,
|
||||||
|
sort_by = "createdAt",
|
||||||
|
sort_dir = "DESC",
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const where = { ...notDeleted };
|
||||||
|
|
||||||
|
if (file_type) where.file_type = file_type;
|
||||||
|
if (owner_type) where.owner_type = owner_type;
|
||||||
|
if (owner_id) where.owner_id = owner_id;
|
||||||
|
if (uploadedBy) where.uploadedBy = uploadedBy;
|
||||||
|
if (resolution) where.resolution = resolution;
|
||||||
|
if (is_public !== undefined) where.is_public = is_public === "true";
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ display_name: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ original_name: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ description: { [Op.iLike]: `%${search}%` } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
const { count, rows } = await Asset.findAndCountAll({
|
||||||
|
where,
|
||||||
|
order: [[sort_by, sort_dir.toUpperCase()]],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
data: rows,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
totalPages: Math.ceil(count / parseInt(limit)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ASSET][GET ALL]", err);
|
||||||
|
return res.status(500).json({ message: "Internal server error." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getAsset = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { assetId } = req.params;
|
||||||
|
if (!assetId || assetId === "undefined") {
|
||||||
|
return res.status(400).json({ message: "Invalid asset ID." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await Asset.findOne({
|
||||||
|
where: { asset_id: assetId, ...notDeleted },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!asset) return res.status(404).json({ message: "Asset not found." });
|
||||||
|
|
||||||
|
return res.status(200).json({ data: asset });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ASSET][GET ONE]", err);
|
||||||
|
return res.status(500).json({ message: "Internal server error." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── UPLOAD ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.uploadAsset = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const file = req.file;
|
||||||
|
if (!file) return res.status(400).json({ message: "No file uploaded." });
|
||||||
|
|
||||||
|
const {
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
owner_type,
|
||||||
|
owner_id,
|
||||||
|
is_public = false,
|
||||||
|
access_level = "private",
|
||||||
|
storage_provider = "local",
|
||||||
|
storage_bucket,
|
||||||
|
storage_key,
|
||||||
|
uploadedBy,
|
||||||
|
file_url: bodyFileUrl,
|
||||||
|
|
||||||
|
// video metadata — from ffprobe pipeline or client
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
duration,
|
||||||
|
frame_rate,
|
||||||
|
bitrate,
|
||||||
|
video_codec,
|
||||||
|
audio_codec,
|
||||||
|
thumbnail_url,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!uploadedBy) {
|
||||||
|
return res.status(400).json({ message: "uploadedBy is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime_type = file.mimetype;
|
||||||
|
const file_type = resolveFileType(mime_type);
|
||||||
|
const extension = resolveExtension(file.originalname);
|
||||||
|
const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
|
||||||
|
|
||||||
|
const parsedWidth = width ? parseInt(width) : null;
|
||||||
|
const parsedHeight = height ? parseInt(height) : null;
|
||||||
|
|
||||||
|
const resolution = file_type === "video"
|
||||||
|
? resolveResolution(parsedWidth, parsedHeight)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const file_url = storage_provider === "local"
|
||||||
|
? `/uploads/${file.filename}`
|
||||||
|
: bodyFileUrl;
|
||||||
|
|
||||||
|
if (!file_url) {
|
||||||
|
return res.status(400).json({ message: "file_url is required for non-local storage." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await Asset.create({
|
||||||
|
original_name: file.originalname,
|
||||||
|
display_name: display_name || file.originalname,
|
||||||
|
file_url,
|
||||||
|
file_size: file.size,
|
||||||
|
mime_type,
|
||||||
|
extension,
|
||||||
|
checksum,
|
||||||
|
file_type,
|
||||||
|
width: parsedWidth,
|
||||||
|
height: parsedHeight,
|
||||||
|
duration: duration ? parseFloat(duration) : null,
|
||||||
|
frame_rate: frame_rate ? parseFloat(frame_rate) : null,
|
||||||
|
bitrate: bitrate ? parseInt(bitrate) : null,
|
||||||
|
video_codec: video_codec || null,
|
||||||
|
audio_codec: audio_codec || null,
|
||||||
|
thumbnail_url: thumbnail_url || null,
|
||||||
|
resolution,
|
||||||
|
description,
|
||||||
|
storage_provider,
|
||||||
|
storage_bucket: storage_bucket || null,
|
||||||
|
storage_key: storage_key || file.filename,
|
||||||
|
is_public,
|
||||||
|
access_level,
|
||||||
|
owner_type: owner_type || null,
|
||||||
|
owner_id: owner_id || null,
|
||||||
|
uploadedBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ data: asset });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ASSET][UPLOAD]", err);
|
||||||
|
return res.status(500).json({ message: "Internal server error." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── UPDATE THUMBNAIL ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.updateThumbnail = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const { thumbnail_url } = req.body;
|
||||||
|
|
||||||
|
if (!thumbnail_url) {
|
||||||
|
return res.status(400).json({ message: "thumbnail_url is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await Asset.findOne({
|
||||||
|
where: { asset_id: assetId, ...notDeleted },
|
||||||
|
});
|
||||||
|
if (!asset) return res.status(404).json({ message: "Asset not found." });
|
||||||
|
|
||||||
|
asset.thumbnail_url = thumbnail_url;
|
||||||
|
await asset.save();
|
||||||
|
|
||||||
|
return res.status(200).json({ data: asset });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ASSET][UPDATE THUMBNAIL]", err);
|
||||||
|
return res.status(500).json({ message: "Internal server error." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── UPDATE METADATA ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.updateAsset = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { assetId } = req.params;
|
||||||
|
if (!assetId || assetId === "undefined") {
|
||||||
|
return res.status(400).json({ message: "Invalid asset ID." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await Asset.findOne({
|
||||||
|
where: { asset_id: assetId, ...notDeleted },
|
||||||
|
});
|
||||||
|
if (!asset) return res.status(404).json({ message: "Asset not found." });
|
||||||
|
|
||||||
|
const allowed = [
|
||||||
|
"display_name", "description",
|
||||||
|
"owner_type", "owner_id",
|
||||||
|
"is_public", "access_level",
|
||||||
|
"thumbnail_url",
|
||||||
|
"width", "height", "duration",
|
||||||
|
"frame_rate", "bitrate",
|
||||||
|
"video_codec", "audio_codec",
|
||||||
|
];
|
||||||
|
|
||||||
|
allowed.forEach((field) => {
|
||||||
|
if (req.body[field] !== undefined) asset[field] = req.body[field];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-derive resolution if dimensions were updated
|
||||||
|
if (req.body.width || req.body.height) {
|
||||||
|
asset.resolution = resolveResolution(asset.width, asset.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
await asset.save();
|
||||||
|
|
||||||
|
return res.status(200).json({ data: asset });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ASSET][UPDATE]", err);
|
||||||
|
return res.status(500).json({ message: "Internal server error." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── SOFT DELETE (single) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.deleteAsset = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { assetId } = req.params;
|
||||||
|
if (!assetId || assetId === "undefined") {
|
||||||
|
return res.status(400).json({ message: "Invalid asset ID." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await Asset.findOne({
|
||||||
|
where: { asset_id: assetId, ...notDeleted },
|
||||||
|
});
|
||||||
|
if (!asset) return res.status(404).json({ message: "Asset not found." });
|
||||||
|
|
||||||
|
asset.deletedAt = new Date();
|
||||||
|
asset.deletedBy = req.body.deletedBy ?? null;
|
||||||
|
await asset.save();
|
||||||
|
|
||||||
|
return res.status(200).json({ message: "Asset deleted." });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ASSET][DELETE]", err);
|
||||||
|
return res.status(500).json({ message: "Internal server error." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── SOFT DELETE (bulk) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.deleteAssets = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ids, deletedBy } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(ids) || !ids.length) {
|
||||||
|
return res.status(400).json({ message: "ids must be a non-empty array." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [count] = await Asset.update(
|
||||||
|
{ deletedAt: new Date(), deletedBy: deletedBy ?? null },
|
||||||
|
{ where: { asset_id: { [Op.in]: ids }, ...notDeleted } },
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({ message: `${count} asset(s) deleted.` });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ASSET][BULK DELETE]", err);
|
||||||
|
return res.status(500).json({ message: "Internal server error." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.restoreAsset = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { assetId } = req.params;
|
||||||
|
|
||||||
|
const asset = await Asset.findOne({
|
||||||
|
where: { asset_id: assetId, deletedAt: { [Op.not]: null } },
|
||||||
|
});
|
||||||
|
if (!asset) {
|
||||||
|
return res.status(404).json({ message: "Asset not found or not deleted." });
|
||||||
|
}
|
||||||
|
|
||||||
|
asset.deletedAt = null;
|
||||||
|
asset.deletedBy = null;
|
||||||
|
await asset.save();
|
||||||
|
|
||||||
|
return res.status(200).json({ data: asset, message: "Asset restored." });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ASSET][RESTORE]", err);
|
||||||
|
return res.status(500).json({ message: "Internal server error." });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: dashboard.controller.js (admin)
|
||||||
|
* Type of Program: Controller
|
||||||
|
* Description: Admin dashboard — users and groups stats + breakdowns.
|
||||||
|
*
|
||||||
|
* Author: rgrgogu
|
||||||
|
* Date Created: Oct. 6, 2025
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
const { Op, fn, col, literal } = require('sequelize');
|
||||||
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
|
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
|
|
||||||
|
// ─── USERS DASHBOARD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getUsersDashboard = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
totalUsers,
|
||||||
|
activeUsers,
|
||||||
|
verifiedUsers,
|
||||||
|
archivedUsers,
|
||||||
|
accTypeBreakdown,
|
||||||
|
regTypeBreakdown,
|
||||||
|
] = await Promise.all([
|
||||||
|
mdl_Users.count({ paranoid: false }),
|
||||||
|
mdl_Users.count({ where: { is_active: true } }),
|
||||||
|
mdl_Users.count({ where: { is_verified: true } }),
|
||||||
|
mdl_Users.count({ where: { deletedAt: { [Op.ne]: null } }, paranoid: false }),
|
||||||
|
mdl_Users.findAll({
|
||||||
|
attributes: ['acc_type', [fn('COUNT', col('user_id')), 'count']],
|
||||||
|
group: ['acc_type'],
|
||||||
|
raw: true,
|
||||||
|
}),
|
||||||
|
mdl_Users.findAll({
|
||||||
|
attributes: ['reg_type', [fn('COUNT', col('user_id')), 'count']],
|
||||||
|
group: ['reg_type'],
|
||||||
|
raw: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Users dashboard data fetched.',
|
||||||
|
data: {
|
||||||
|
stats: [
|
||||||
|
{ key: 'total', label: 'Total Users', value: totalUsers },
|
||||||
|
{ key: 'active', label: 'Active Users', value: activeUsers },
|
||||||
|
{ key: 'inactive', label: 'Inactive Users', value: totalUsers - archivedUsers - activeUsers },
|
||||||
|
{ key: 'verified', label: 'Verified', value: verifiedUsers },
|
||||||
|
{ key: 'archived', label: 'Archived', value: archivedUsers },
|
||||||
|
],
|
||||||
|
breakdowns: [
|
||||||
|
{
|
||||||
|
key: 'acc_type',
|
||||||
|
label: 'By Account Type',
|
||||||
|
data: accTypeBreakdown.map((r) => ({ label: r.acc_type, value: parseInt(r.count, 10) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reg_type',
|
||||||
|
label: 'By Registration Type',
|
||||||
|
data: regTypeBreakdown.map((r) => ({ label: r.reg_type, value: parseInt(r.count, 10) })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][DASHBOARD][USERS]', err);
|
||||||
|
return res.status(500).json({ status: 'error', message: 'Could not fetch users dashboard data.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── GROUPS DASHBOARD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getGroupsDashboard = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
totalGroups,
|
||||||
|
activeGroups,
|
||||||
|
archivedGroups,
|
||||||
|
emptyGroups,
|
||||||
|
memberCountBreakdown,
|
||||||
|
] = await Promise.all([
|
||||||
|
mdl_UserGroups.count({ paranoid: false }),
|
||||||
|
mdl_UserGroups.count({ where: { is_active: true } }),
|
||||||
|
mdl_UserGroups.count({ where: { deletedAt: { [Op.ne]: null } }, paranoid: false }),
|
||||||
|
mdl_UserGroups.count({
|
||||||
|
where: { is_active: true },
|
||||||
|
include: [{
|
||||||
|
model: mdl_UserGroupMembers,
|
||||||
|
required: false,
|
||||||
|
where: { deletedAt: null },
|
||||||
|
}],
|
||||||
|
having: literal('COUNT("UserGroupMembers"."user_id") = 0'),
|
||||||
|
group: ['UserGroup.group_id'],
|
||||||
|
subQuery: false,
|
||||||
|
}).catch(() => 0),
|
||||||
|
mdl_UserGroupMembers.findAll({
|
||||||
|
attributes: [
|
||||||
|
'group_id',
|
||||||
|
[fn('COUNT', col('user_id')), 'member_count'],
|
||||||
|
],
|
||||||
|
where: { deletedAt: null },
|
||||||
|
include: [{
|
||||||
|
model: mdl_UserGroups,
|
||||||
|
attributes: ['name'],
|
||||||
|
where: { is_active: true },
|
||||||
|
}],
|
||||||
|
group: ['UserGroupMember.group_id', 'UserGroup.group_id', 'UserGroup.name'],
|
||||||
|
order: [[literal('member_count'), 'DESC']],
|
||||||
|
limit: 10,
|
||||||
|
raw: true,
|
||||||
|
nest: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Groups dashboard data fetched.',
|
||||||
|
data: {
|
||||||
|
stats: [
|
||||||
|
{ key: 'total', label: 'Total Groups', value: totalGroups },
|
||||||
|
{ key: 'active', label: 'Active Groups', value: activeGroups },
|
||||||
|
{ key: 'inactive', label: 'Inactive Groups', value: totalGroups - archivedGroups - activeGroups },
|
||||||
|
{ key: 'archived', label: 'Archived', value: archivedGroups },
|
||||||
|
{ key: 'empty', label: 'Empty Groups', value: Array.isArray(emptyGroups) ? emptyGroups.length : emptyGroups },
|
||||||
|
],
|
||||||
|
breakdowns: [
|
||||||
|
{
|
||||||
|
key: 'top_groups',
|
||||||
|
label: 'Top Groups by Members',
|
||||||
|
data: memberCountBreakdown.map((r) => ({
|
||||||
|
label: r.UserGroup?.name ?? `Group ${r.group_id}`,
|
||||||
|
value: parseInt(r.member_count, 10),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][DASHBOARD][GROUPS]', err);
|
||||||
|
return res.status(500).json({ status: 'error', message: 'Could not fetch groups dashboard data.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# Assets Controller Documentation
|
||||||
|
|
||||||
|
**File:** `controllers/admin/assets.controller.js`
|
||||||
|
**Base URL:** `/api/admin/assets`
|
||||||
|
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Get All Assets](#get-all-assets)
|
||||||
|
- [Get Single Asset](#get-single-asset)
|
||||||
|
- [Upload Asset](#upload-asset)
|
||||||
|
- [Update Asset Metadata](#update-asset-metadata)
|
||||||
|
- [Update Thumbnail](#update-thumbnail)
|
||||||
|
- [Delete Asset](#delete-asset)
|
||||||
|
- [Bulk Delete Assets](#bulk-delete-assets)
|
||||||
|
- [Restore Asset](#restore-asset)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get All Assets
|
||||||
|
|
||||||
|
**`GET /api/admin/assets`**
|
||||||
|
|
||||||
|
Returns a paginated list of non-deleted assets with optional filtering.
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-------------|---------|----------|--------------------------------------------------|
|
||||||
|
| page | number | No | Page number. Default: `1` |
|
||||||
|
| limit | number | No | Records per page. Default: `20` |
|
||||||
|
| file_type | string | No | Filter by type: `image`, `video`, `document`, `other` |
|
||||||
|
| owner_type | string | No | Filter by owner type e.g. `User`, `Course` |
|
||||||
|
| owner_id | number | No | Filter by owner ID |
|
||||||
|
| uploadedBy | number | No | Filter by uploader user ID |
|
||||||
|
| is_public | boolean | No | Filter by visibility: `true` or `false` |
|
||||||
|
| resolution | string | No | Filter by resolution e.g. `1080p`, `720p` |
|
||||||
|
| search | string | No | Search by `display_name`, `original_name`, `description` |
|
||||||
|
| sort_by | string | No | Column to sort by. Default: `createdAt` |
|
||||||
|
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Assets retrieved.",
|
||||||
|
"data": {
|
||||||
|
"rows": [...],
|
||||||
|
"pagination": {
|
||||||
|
"total": 100,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"totalPages": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Single Asset
|
||||||
|
|
||||||
|
**`GET /api/admin/assets/:assetId`**
|
||||||
|
|
||||||
|
Returns a single non-deleted asset by ID.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|--------------|
|
||||||
|
| assetId | number | Yes | Asset ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Asset found.",
|
||||||
|
"data": {
|
||||||
|
"asset_id": 1,
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"original_name": "intro.mp4",
|
||||||
|
"display_name": "Course Intro Video",
|
||||||
|
"file_url": "/uploads/intro.mp4",
|
||||||
|
"file_size": 104857600,
|
||||||
|
"mime_type": "video/mp4",
|
||||||
|
"extension": "mp4",
|
||||||
|
"checksum": "a3f5...",
|
||||||
|
"file_type": "video",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"duration": 120.5,
|
||||||
|
"resolution": "1080p",
|
||||||
|
"frame_rate": 29.97,
|
||||||
|
"bitrate": 8000000,
|
||||||
|
"video_codec": "H.264",
|
||||||
|
"audio_codec": "AAC",
|
||||||
|
"thumbnail_url": "/uploads/thumbnails/intro.jpg",
|
||||||
|
"description": "Introduction to the course.",
|
||||||
|
"storage_provider": "local",
|
||||||
|
"storage_bucket": null,
|
||||||
|
"storage_key": "intro.mp4",
|
||||||
|
"is_public": true,
|
||||||
|
"access_level": "public",
|
||||||
|
"owner_type": "Course",
|
||||||
|
"owner_id": 3,
|
||||||
|
"uploadedBy": 1,
|
||||||
|
"deletedBy": null,
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"deletedAt": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `404`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Asset not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upload Asset
|
||||||
|
|
||||||
|
**`POST /api/admin/assets/upload`**
|
||||||
|
|
||||||
|
Uploads a new asset. Expects `multipart/form-data`.
|
||||||
|
Video metadata (`width`, `height`, `duration`, etc.) should be extracted via **ffprobe** server-side or passed from the client.
|
||||||
|
`resolution` is **auto-derived** from `width` and `height` — do not pass it manually.
|
||||||
|
|
||||||
|
### Request `multipart/form-data`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-----------------|---------|----------|----------------------------------------------------------|
|
||||||
|
| file | File | Yes | The file to upload |
|
||||||
|
| uploadedBy | number | Yes | User ID of the uploader |
|
||||||
|
| display_name | string | No | Display name shown on platform. Defaults to filename |
|
||||||
|
| description | string | No | Description of the asset |
|
||||||
|
| owner_type | string | No | Owning entity type e.g. `Course`, `User` |
|
||||||
|
| owner_id | number | No | Owning entity ID |
|
||||||
|
| is_public | boolean | No | Whether asset is publicly accessible. Default: `false` |
|
||||||
|
| access_level | string | No | `public`, `private`, `restricted`. Default: `private` |
|
||||||
|
| storage_provider| string | No | `local`, `s3`, `gcs`, `cloudinary`, `chibisafe`, `other`. Default: `local` |
|
||||||
|
| storage_bucket | string | No | Bucket/container name for cloud storage |
|
||||||
|
| storage_key | string | No | Object key/path in bucket |
|
||||||
|
| file_url | string | No* | Required for non-local storage providers |
|
||||||
|
| width | number | No | Video/image width in px |
|
||||||
|
| height | number | No | Video/image height in px |
|
||||||
|
| duration | number | No | Video duration in seconds |
|
||||||
|
| frame_rate | number | No | Video frame rate in fps |
|
||||||
|
| bitrate | number | No | Video bitrate in bps |
|
||||||
|
| video_codec | string | No | Video codec e.g. `H.264`, `H.265` |
|
||||||
|
| audio_codec | string | No | Audio codec e.g. `AAC`, `MP3` |
|
||||||
|
| thumbnail_url | string | No | URL of the video/document preview thumbnail |
|
||||||
|
|
||||||
|
### Resolution Auto-Derivation
|
||||||
|
| Height (px) | Derived Resolution |
|
||||||
|
|-------------|-------------------|
|
||||||
|
| ≥ 2160 | `4K` |
|
||||||
|
| ≥ 1440 | `1440p` |
|
||||||
|
| ≥ 1080 | `1080p` |
|
||||||
|
| ≥ 720 | `720p` |
|
||||||
|
| ≥ 480 | `480p` |
|
||||||
|
| ≥ 360 | `360p` |
|
||||||
|
| ≥ 240 | `240p` |
|
||||||
|
| Other | `{width}x{height}`|
|
||||||
|
|
||||||
|
### Response `201`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Asset uploaded.",
|
||||||
|
"data": { ...asset }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `400`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "No file uploaded."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update Asset Metadata
|
||||||
|
|
||||||
|
**`PUT /api/admin/assets/:assetId`**
|
||||||
|
|
||||||
|
Updates metadata of an existing asset. File replacement is not supported — upload a new asset instead.
|
||||||
|
`resolution` is **auto-re-derived** if `width` or `height` is updated.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| assetId | number | Yes | Asset ID |
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|--------------|---------|----------|------------------------------------------|
|
||||||
|
| display_name | string | No | Updated display name |
|
||||||
|
| description | string | No | Updated description |
|
||||||
|
| owner_type | string | No | Updated owner type |
|
||||||
|
| owner_id | number | No | Updated owner ID |
|
||||||
|
| is_public | boolean | No | Updated visibility |
|
||||||
|
| access_level | string | No | Updated access level |
|
||||||
|
| thumbnail_url | string | No | Updated thumbnail URL |
|
||||||
|
| width | number | No | Updated width — re-derives resolution |
|
||||||
|
| height | number | No | Updated height — re-derives resolution |
|
||||||
|
| duration | number | No | Updated duration |
|
||||||
|
| frame_rate | number | No | Updated frame rate |
|
||||||
|
| bitrate | number | No | Updated bitrate |
|
||||||
|
| video_codec | string | No | Updated video codec |
|
||||||
|
| audio_codec | string | No | Updated audio codec |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Asset updated.",
|
||||||
|
"data": { ...asset }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update Thumbnail
|
||||||
|
|
||||||
|
**`PATCH /api/admin/assets/:assetId/thumbnail`**
|
||||||
|
|
||||||
|
Updates only the thumbnail of an asset. Useful for video platforms where users frequently change the video cover independently.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| assetId | number | Yes | Asset ID |
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|--------------|--------|----------|-------------------------|
|
||||||
|
| thumbnail_url | string | Yes | New thumbnail URL |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Thumbnail updated.",
|
||||||
|
"data": { ...asset }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `400`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "thumbnail_url is required."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delete Asset
|
||||||
|
|
||||||
|
**`DELETE /api/admin/assets/:assetId`**
|
||||||
|
|
||||||
|
Soft deletes a single asset by setting `deletedAt` and `deletedBy`.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| assetId | number | Yes | Asset ID |
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|----------|--------|----------|--------------------------------|
|
||||||
|
| deletedBy | number | No | User ID of who deleted the asset |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Asset deleted."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bulk Delete Assets
|
||||||
|
|
||||||
|
**`DELETE /api/admin/assets/bulk`**
|
||||||
|
|
||||||
|
Soft deletes multiple assets at once.
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|----------|----------|----------|----------------------------------|
|
||||||
|
| ids | number[] | Yes | Array of asset IDs to delete |
|
||||||
|
| deletedBy | number | No | User ID of who deleted the assets |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "3 asset(s) deleted."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `400`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "ids must be a non-empty array."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restore Asset
|
||||||
|
|
||||||
|
**`PATCH /api/admin/assets/:assetId/restore`**
|
||||||
|
|
||||||
|
Restores a soft-deleted asset by clearing `deletedAt` and `deletedBy`.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| assetId | number | Yes | Asset ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Asset restored.",
|
||||||
|
"data": { ...asset }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `404`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Asset not found or not deleted."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
All endpoints return the following on server error:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Internal server error."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Size Limits
|
||||||
|
|
||||||
|
| Type | Max Size |
|
||||||
|
|----------|----------|
|
||||||
|
| Images | 10 GB |
|
||||||
|
| Videos | 10 GB |
|
||||||
|
| Documents| 10 GB |
|
||||||
|
|
||||||
|
> Limit is applied at the multer middleware level. Adjust in `assets.routes.js` if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **File replacement** is not supported. To replace a file, delete the old asset and upload a new one.
|
||||||
|
- **Checksum** (SHA-256) is computed on upload for duplicate detection.
|
||||||
|
- **Polymorphic ownership** via `owner_type` + `owner_id` allows any entity (`Course`, `User`, `Post`, etc.) to own assets without a direct foreign key.
|
||||||
|
- **Resolution** is always auto-derived from `width` and `height` — never set manually.
|
||||||
|
- **Soft delete** sets `deletedAt` timestamp. Assets are excluded from all queries unless explicitly queried with `paranoid: false`.
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
# User Groups Controller Documentation
|
||||||
|
|
||||||
|
**File:** `controllers/admin/user_groups.controller.js`
|
||||||
|
**Base URL:** `/api/admin/groups`
|
||||||
|
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Get All Groups](#get-all-groups)
|
||||||
|
- [Get Single Group](#get-single-group)
|
||||||
|
- [Create Group](#create-group)
|
||||||
|
- [Update Group](#update-group)
|
||||||
|
- [Deactivate Group](#deactivate-group)
|
||||||
|
- [Bulk Deactivate Groups](#bulk-deactivate-groups)
|
||||||
|
- [Restore Group](#restore-group)
|
||||||
|
- [Bulk Restore Groups](#bulk-restore-groups)
|
||||||
|
- [Get Archived Groups](#get-archived-groups)
|
||||||
|
- [Get Group Field Values](#get-group-field-values)
|
||||||
|
- [Get Users In Group](#get-users-in-group)
|
||||||
|
- [Get Users Not In Group](#get-users-not-in-group)
|
||||||
|
- [Add Users To Group](#add-users-to-group)
|
||||||
|
- [Remove Users From Group](#remove-users-from-group)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get All Groups
|
||||||
|
|
||||||
|
**`GET /api/admin/groups`**
|
||||||
|
|
||||||
|
Returns a paginated list of active groups.
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|----------|--------|----------|--------------------------------------------------|
|
||||||
|
| page | number | No | Page number. Default: `1` |
|
||||||
|
| limit | number | No | Records per page. Default: `20` |
|
||||||
|
| search | string | No | Search across group fields |
|
||||||
|
| sort_by | string | No | Column to sort by. Default: `createdAt` |
|
||||||
|
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
|
||||||
|
| filters | array | No | Column filters from DataTable |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Groups retrieved.",
|
||||||
|
"data": {
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"group_id": 1,
|
||||||
|
"name": "Administrators",
|
||||||
|
"description": "Full access group.",
|
||||||
|
"is_active": true,
|
||||||
|
"member_count": 5,
|
||||||
|
"createdBy": 1,
|
||||||
|
"updatedBy": null,
|
||||||
|
"deletedBy": null,
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"deletedAt": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"total": 10,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"totalPages": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Single Group
|
||||||
|
|
||||||
|
**`GET /api/admin/groups/:gid`**
|
||||||
|
|
||||||
|
Returns a single group with a paginated list of its members.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| gid | number | Yes | Group ID |
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
Same pagination/filter params as [Get All Groups](#get-all-groups) — applied to the members list.
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Group retrieved.",
|
||||||
|
"data": {
|
||||||
|
"group": {
|
||||||
|
"group_id": 1,
|
||||||
|
"name": "Administrators",
|
||||||
|
"description": "Full access group.",
|
||||||
|
"is_active": true,
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"rows": [ ...users ],
|
||||||
|
"pagination": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `404`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Group not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create Group
|
||||||
|
|
||||||
|
**`POST /api/admin/groups`**
|
||||||
|
|
||||||
|
Creates a new user group.
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|------------|--------|----------|--------------------|
|
||||||
|
| name | string | Yes | Group name |
|
||||||
|
| description | string | No | Group description |
|
||||||
|
|
||||||
|
### Response `201`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Group created.",
|
||||||
|
"data": {
|
||||||
|
"group_id": 1,
|
||||||
|
"name": "Administrators",
|
||||||
|
"description": "Full access group.",
|
||||||
|
"is_active": true,
|
||||||
|
"createdBy": 1,
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `400`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Group name is required."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update Group
|
||||||
|
|
||||||
|
**`PUT /api/admin/groups/:gid`**
|
||||||
|
|
||||||
|
Updates a group's name or description.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| gid | number | Yes | Group ID |
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|------------|--------|----------|------------------------|
|
||||||
|
| name | string | No | Updated group name |
|
||||||
|
| description | string | No | Updated description |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Group updated.",
|
||||||
|
"data": { ...group }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deactivate Group
|
||||||
|
|
||||||
|
**`PATCH /api/admin/groups/:gid/deactivate`**
|
||||||
|
|
||||||
|
Soft deletes a group by setting `deletedAt` and `is_active: false`.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| gid | number | Yes | Group ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Group deactivated."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `400`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Group is already deactivated."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bulk Deactivate Groups
|
||||||
|
|
||||||
|
**`DELETE /api/admin/groups/bulk`**
|
||||||
|
|
||||||
|
Soft deletes multiple groups at once.
|
||||||
|
Already-deactivated groups are skipped and reported.
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|----------|----------|---------------------------|
|
||||||
|
| ids | number[] | Yes | Array of group IDs |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "3 group(s) deactivated successfully.",
|
||||||
|
"data": {
|
||||||
|
"deactivated_ids": [1, 2, 3],
|
||||||
|
"skipped_ids": [4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restore Group
|
||||||
|
|
||||||
|
**`PATCH /api/admin/groups/:gid/restore`**
|
||||||
|
|
||||||
|
Restores a soft-deleted group by clearing `deletedAt` and setting `is_active: true`.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| gid | number | Yes | Group ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Group restored."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `400`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Group is already active."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bulk Restore Groups
|
||||||
|
|
||||||
|
**`POST /api/admin/groups/bulk/restore`**
|
||||||
|
|
||||||
|
Restores multiple soft-deleted groups at once.
|
||||||
|
Already-active groups are skipped and reported.
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|----------|----------|---------------------------|
|
||||||
|
| ids | number[] | Yes | Array of group IDs |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "3 group(s) restored successfully.",
|
||||||
|
"data": {
|
||||||
|
"restored_ids": [1, 2, 3],
|
||||||
|
"skipped_ids": [4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Archived Groups
|
||||||
|
|
||||||
|
**`GET /api/admin/groups/archived`**
|
||||||
|
|
||||||
|
Returns a paginated list of soft-deleted groups.
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
Same as [Get All Groups](#get-all-groups).
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Archived groups retrieved.",
|
||||||
|
"data": {
|
||||||
|
"rows": [ ...soft-deleted groups ],
|
||||||
|
"pagination": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Group Field Values
|
||||||
|
|
||||||
|
**`GET /api/admin/groups/field-values`**
|
||||||
|
|
||||||
|
Returns distinct values for a given column — used to populate filter dropdowns in the DataTable.
|
||||||
|
Supports regular columns, date fields, and audit fields.
|
||||||
|
JSONB fields are not supported for groups.
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|----------|--------|----------|--------------------|
|
||||||
|
| field | string | Yes | Column name |
|
||||||
|
|
||||||
|
### Supported Field Types
|
||||||
|
| Type | Example | Returns |
|
||||||
|
|----------|-------------|--------------------------------|
|
||||||
|
| Regular | `is_active` | Distinct values |
|
||||||
|
| Date | `createdAt` | Distinct dates (no time) |
|
||||||
|
| Audit by | `createdBy` | Full names of referenced users |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Field values retrieved.",
|
||||||
|
"data": ["true", "false"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Users In Group
|
||||||
|
|
||||||
|
**`GET /api/admin/groups/:gid/users`**
|
||||||
|
|
||||||
|
Returns all current members of a group with their `user_id` and `full_name`.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| gid | number | Yes | Group ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Group members fetched.",
|
||||||
|
"data": [
|
||||||
|
{ "user_id": 1, "full_name": "John Doe" },
|
||||||
|
{ "user_id": 2, "full_name": "Jane Smith" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Users Not In Group
|
||||||
|
|
||||||
|
**`GET /api/admin/groups/:gid/users/add`**
|
||||||
|
|
||||||
|
Returns all users who are **not** currently members of the group.
|
||||||
|
Used to populate the Add Members sheet.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| gid | number | Yes | Group ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Users fetched.",
|
||||||
|
"data": [
|
||||||
|
{ "user_id": 3, "full_name": "Alice Johnson" },
|
||||||
|
{ "user_id": 4, "full_name": "Bob Williams" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add Users To Group
|
||||||
|
|
||||||
|
**`POST /api/admin/groups/:gid/users`**
|
||||||
|
|
||||||
|
Adds one or more users to a group.
|
||||||
|
If a user was previously removed (soft-deleted membership), their membership is restored instead of duplicated.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| gid | number | Yes | Group ID |
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---------|----------|----------|------------------------------|
|
||||||
|
| user_ids | number[] | Yes | Array of user IDs to add |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Users added to group."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `404`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Users not found: 5, 6"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remove Users From Group
|
||||||
|
|
||||||
|
**`DELETE /api/admin/groups/:gid/users`**
|
||||||
|
|
||||||
|
Removes one or more users from a group via soft delete on the membership record.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| gid | number | Yes | Group ID |
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---------|----------|---------|---------------------------------|
|
||||||
|
| user_ids | number[] | Yes | Array of user IDs to remove |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Users removed from group."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `404`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Memberships not found for users: 5, 6"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
All endpoints return the following on server error:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Internal server error."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Soft delete** — deactivation sets `deletedAt` + `is_active: false`. Groups are excluded from all queries unless explicitly queried with `paranoid: false`.
|
||||||
|
- **Membership soft delete** — removing a user from a group soft-deletes the membership record. Re-adding the user restores the record rather than creating a duplicate.
|
||||||
|
- **Audit fields** — `createdBy`, `updatedBy`, `deletedBy` store the `user_id` of the admin who performed the action.
|
||||||
|
- **JSONB** — group fields do not support JSONB dot-notation filtering unlike users.
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
# Users Controller Documentation
|
||||||
|
|
||||||
|
**File:** `controllers/admin/users.controller.js`
|
||||||
|
**Base URL:** `/api/admin/users`
|
||||||
|
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Get All Users](#get-all-users)
|
||||||
|
- [Get Single User](#get-single-user)
|
||||||
|
- [Add Staff User](#add-staff-user)
|
||||||
|
- [Update User](#update-user)
|
||||||
|
- [Deactivate User](#deactivate-user)
|
||||||
|
- [Bulk Deactivate Users](#bulk-deactivate-users)
|
||||||
|
- [Restore User](#restore-user)
|
||||||
|
- [Bulk Restore Users](#bulk-restore-users)
|
||||||
|
- [Get Archived Users](#get-archived-users)
|
||||||
|
- [Get User Field Values](#get-user-field-values)
|
||||||
|
- [Get User Sessions](#get-user-sessions)
|
||||||
|
- [Terminate Session](#terminate-session)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get All Users
|
||||||
|
|
||||||
|
**`GET /api/admin/users`**
|
||||||
|
|
||||||
|
Returns a paginated list of active users.
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|--------------------------------------------------|
|
||||||
|
| page | number | No | Page number. Default: `1` |
|
||||||
|
| limit | number | No | Records per page. Default: `20` |
|
||||||
|
| search | string | No | Search across user fields |
|
||||||
|
| sort_by | string | No | Column to sort by. Default: `createdAt` |
|
||||||
|
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
|
||||||
|
| filters | array | No | Column filters from DataTable |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Users retrieved.",
|
||||||
|
"data": {
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"user_id": 1,
|
||||||
|
"email": "john@example.com",
|
||||||
|
"acc_type": "admin",
|
||||||
|
"reg_type": "system",
|
||||||
|
"is_active": true,
|
||||||
|
"is_verified": true,
|
||||||
|
"personal_info": {
|
||||||
|
"name": {
|
||||||
|
"given_name": "John",
|
||||||
|
"middle_name": null,
|
||||||
|
"last_name": "Doe",
|
||||||
|
"extension_name": null,
|
||||||
|
"full_name": "John Doe"
|
||||||
|
},
|
||||||
|
"date_of_birth": null,
|
||||||
|
"occupation": null,
|
||||||
|
"addresses": [],
|
||||||
|
"phone_number": []
|
||||||
|
},
|
||||||
|
"groups": [],
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"deletedAt": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"total": 100,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"totalPages": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Single User
|
||||||
|
|
||||||
|
**`GET /api/admin/users/:id`**
|
||||||
|
|
||||||
|
Returns a single user with their group memberships.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| id | number | Yes | User ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "User retrieved.",
|
||||||
|
"data": {
|
||||||
|
"user_id": 1,
|
||||||
|
"email": "john@example.com",
|
||||||
|
"acc_type": "admin",
|
||||||
|
"is_active": true,
|
||||||
|
"is_verified": true,
|
||||||
|
"personal_info": { ... },
|
||||||
|
"groups": [
|
||||||
|
{ "group_id": 1, "name": "Administrators" }
|
||||||
|
],
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `404`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "User not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add Staff User
|
||||||
|
|
||||||
|
**`POST /api/admin/users/staff`**
|
||||||
|
|
||||||
|
Creates a new staff user with an auto-generated temporary password.
|
||||||
|
A welcome email is sent with the credentials and a 24-hour expiry notice.
|
||||||
|
The user is forced to change their password on first login.
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-----------------------------|--------|----------|--------------------------------------|
|
||||||
|
| email | string | Yes | Staff user email address |
|
||||||
|
| personal_info.name.given_name | string | Yes | First name |
|
||||||
|
| personal_info.name.last_name | string | Yes | Last name |
|
||||||
|
| personal_info.name.middle_name | string | No | Middle name |
|
||||||
|
| personal_info.name.extension_name | string | No | Extension name e.g. `Jr.` |
|
||||||
|
| personal_info.date_of_birth | string | No | Date of birth |
|
||||||
|
| personal_info.occupation | string | No | Occupation |
|
||||||
|
|
||||||
|
### Response `201`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Staff user created successfully.",
|
||||||
|
"data": {
|
||||||
|
"user_id": 5,
|
||||||
|
"email": "staff@example.com",
|
||||||
|
"acc_type": "staff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `409`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Email is already in use."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update User
|
||||||
|
|
||||||
|
**`PUT /api/admin/users/:id`**
|
||||||
|
|
||||||
|
Updates a user's account type, active status, or personal information.
|
||||||
|
Admins cannot change their own `acc_type`.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| id | number | Yes | User ID |
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|--------------|---------|----------|----------------------------------------------|
|
||||||
|
| acc_type | string | No | `admin`, `staff`, `user` |
|
||||||
|
| is_active | boolean | No | Active status |
|
||||||
|
| personal_info | object | No | Personal information object |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "User updated.",
|
||||||
|
"data": { ...user }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `400`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Admins cannot change their own role."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deactivate User
|
||||||
|
|
||||||
|
**`DELETE /api/admin/users/:id`**
|
||||||
|
|
||||||
|
Soft deletes a user by setting `deletedAt` and `is_active: false`.
|
||||||
|
All active sessions are force-terminated.
|
||||||
|
Admins cannot deactivate their own account.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| id | number | Yes | User ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "User deactivated successfully."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `400`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "You cannot deactivate your own account."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bulk Deactivate Users
|
||||||
|
|
||||||
|
**`DELETE /api/admin/users/bulk`**
|
||||||
|
|
||||||
|
Soft deletes multiple users at once.
|
||||||
|
Already-deactivated users are skipped and reported.
|
||||||
|
All active sessions for deactivated users are force-terminated.
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|----------|----------|--------------------------|
|
||||||
|
| ids | number[] | Yes | Array of user IDs |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "3 user(s) deactivated successfully.",
|
||||||
|
"data": {
|
||||||
|
"deactivated_ids": [1, 2, 3],
|
||||||
|
"skipped_ids": [4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restore User
|
||||||
|
|
||||||
|
**`POST /api/admin/users/:id/restore`**
|
||||||
|
|
||||||
|
Restores a soft-deleted user by clearing `deletedAt` and setting `is_active: true`.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| id | number | Yes | User ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "User restored successfully."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `400`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "User is not deactivated."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bulk Restore Users
|
||||||
|
|
||||||
|
**`POST /api/admin/users/bulk/restore`**
|
||||||
|
|
||||||
|
Restores multiple soft-deleted users at once.
|
||||||
|
Already-active users are skipped and reported.
|
||||||
|
|
||||||
|
### Request Body `application/json`
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|----------|----------|--------------------------|
|
||||||
|
| ids | number[] | Yes | Array of user IDs |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "3 user(s) restored successfully.",
|
||||||
|
"data": {
|
||||||
|
"restored_ids": [1, 2, 3],
|
||||||
|
"skipped_ids": [4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Archived Users
|
||||||
|
|
||||||
|
**`GET /api/admin/users/archived`**
|
||||||
|
|
||||||
|
Returns a paginated list of soft-deleted users.
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
Same as [Get All Users](#get-all-users).
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Archived users retrieved.",
|
||||||
|
"data": {
|
||||||
|
"rows": [ ...soft-deleted users ],
|
||||||
|
"pagination": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get User Field Values
|
||||||
|
|
||||||
|
**`GET /api/admin/users/field-values`**
|
||||||
|
|
||||||
|
Returns distinct values for a given column — used to populate filter dropdowns in the DataTable.
|
||||||
|
Supports regular columns, date fields, audit fields, and JSONB dot-notation.
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|----------|--------|----------|------------------------------------------------------|
|
||||||
|
| field | string | Yes | Column name or JSONB path e.g. `personal_info.name.given_name` |
|
||||||
|
|
||||||
|
### Supported Field Types
|
||||||
|
| Type | Example | Returns |
|
||||||
|
|-------------|----------------------------------|--------------------------------|
|
||||||
|
| Regular | `acc_type` | Distinct string values |
|
||||||
|
| Date | `createdAt` | Distinct dates (no time) |
|
||||||
|
| Audit by | `createdBy` | Full names of referenced users |
|
||||||
|
| JSONB | `personal_info.name.given_name` | Distinct JSONB path values |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Field values retrieved.",
|
||||||
|
"data": ["admin", "staff", "user"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `400`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Invalid or restricted field."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get User Sessions
|
||||||
|
|
||||||
|
**`GET /api/admin/users/:id/sessions`**
|
||||||
|
|
||||||
|
Returns all sessions for a specific user, ordered by most recent.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-------------|
|
||||||
|
| id | number | Yes | User ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Sessions retrieved.",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"session_id": 1,
|
||||||
|
"user_id": 1,
|
||||||
|
"is_active": true,
|
||||||
|
"ip_address": "192.168.1.1",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"logout_info": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminate Session
|
||||||
|
|
||||||
|
**`DELETE /api/admin/users/:id/sessions/:sid`**
|
||||||
|
|
||||||
|
Force-terminates a specific user session.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|--------------|
|
||||||
|
| id | number | Yes | User ID |
|
||||||
|
| sid | number | Yes | Session ID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Session terminated."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `404`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Session not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
All endpoints return the following on server error:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Internal server error."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Excluded fields** — `password`, `otp_code`, `otp_expires_at`, `must_change_password`, `password_expires_at` are never returned in any response.
|
||||||
|
- **Soft delete** — deactivation sets `deletedAt` + `is_active: false`. Users are excluded from all queries unless explicitly queried with `paranoid: false`.
|
||||||
|
- **Session termination** — deactivating a user (single or bulk) always force-terminates all their active sessions.
|
||||||
|
- **Audit fields** — `createdBy`, `updatedBy`, `deletedBy` store the `user_id` of the admin who performed the action.
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: user_groups.controller.js (admin)
|
||||||
|
* Type of Program: Controller
|
||||||
|
* Description: Admin-level user group management — CRUD + membership.
|
||||||
|
*
|
||||||
|
* Author: rgrgogu
|
||||||
|
* Date Created: Oct. 6, 2025
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
const sequelize = require('../../config/db.config');
|
||||||
|
const { Op, Sequelize } = require('sequelize');
|
||||||
|
|
||||||
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
|
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
|
|
||||||
|
const R = require('../../utils/response.util');
|
||||||
|
const { paginate } = require('../../utils/paginate.util');
|
||||||
|
|
||||||
|
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes');
|
||||||
|
const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes');
|
||||||
|
|
||||||
|
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
|
||||||
|
|
||||||
|
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getGroups = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await paginate(mdl_UserGroups, req, {
|
||||||
|
excludeAttributes: groupExclude,
|
||||||
|
jsonbSchemas: groupSchemas,
|
||||||
|
computedAttributes: groupComputed,
|
||||||
|
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return R.success(res, 'Groups retrieved.', result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET GROUPS]', err);
|
||||||
|
return R.error(res, 'Could not retrieve groups.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getGroup = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||||
|
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' },
|
||||||
|
findOptions: {
|
||||||
|
include: [{
|
||||||
|
model: mdl_UserGroupMembers,
|
||||||
|
where: { group_id: req.params.gid },
|
||||||
|
attributes: [],
|
||||||
|
required: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return R.success(res, 'Group retrieved.', { group, members });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET GROUP]', err);
|
||||||
|
return R.error(res, 'Could not retrieve group.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.createGroup = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, description } = req.body;
|
||||||
|
if (!name) return R.error(res, 'Group name is required.', 400);
|
||||||
|
|
||||||
|
const group = await mdl_UserGroups.create({
|
||||||
|
name, description, createdBy: req.user.user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return R.success(res, 'Group created.', group, 201);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][CREATE GROUP]', err);
|
||||||
|
return R.error(res, 'Could not create group.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.updateGroup = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||||
|
if (!group) return R.error(res, 'Group not found.', 404);
|
||||||
|
|
||||||
|
const { name, description } = req.body;
|
||||||
|
if (name !== undefined) group.name = name;
|
||||||
|
if (description !== undefined) group.description = description;
|
||||||
|
group.updatedBy = req.user.user_id;
|
||||||
|
await group.save();
|
||||||
|
|
||||||
|
return R.success(res, 'Group updated.', group);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][UPDATE GROUP]', err);
|
||||||
|
return R.error(res, 'Could not update group.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── DEACTIVATE ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.deactivateGroup = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||||
|
if (!group) return R.error(res, 'Group not found.', 404);
|
||||||
|
if (!group.is_active) return R.error(res, 'Group is already deactivated.', 400);
|
||||||
|
|
||||||
|
await group.update({ is_active: false, updatedBy: req.user.user_id, deletedBy: req.user.user_id });
|
||||||
|
await group.destroy();
|
||||||
|
|
||||||
|
return R.success(res, 'Group deactivated.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][DEACTIVATE GROUP]', err);
|
||||||
|
return R.error(res, 'Could not deactivate group.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── RESTORE ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.restoreGroup = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const group = await mdl_UserGroups.findOne({
|
||||||
|
where: { group_id: req.params.gid }, paranoid: false,
|
||||||
|
});
|
||||||
|
if (!group) return R.error(res, 'Group not found.', 404);
|
||||||
|
if (group.is_active) return R.error(res, 'Group is already active.', 400);
|
||||||
|
|
||||||
|
await group.restore();
|
||||||
|
await group.update({ is_active: true, updatedBy: req.user.user_id, deletedBy: null });
|
||||||
|
|
||||||
|
return R.success(res, 'Group restored.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][RESTORE GROUP]', err);
|
||||||
|
return R.error(res, 'Could not restore group.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── BULK DEACTIVATE ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.bulkDeactivateGroups = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ids } = req.body;
|
||||||
|
if (!Array.isArray(ids) || !ids.length)
|
||||||
|
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 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.bulkRestoreGroups = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ids } = req.body;
|
||||||
|
if (!Array.isArray(ids) || !ids.length)
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── ARCHIVED ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getArchivedGroups = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await paginate(mdl_UserGroups, req, {
|
||||||
|
excludeAttributes: groupExclude,
|
||||||
|
jsonbSchemas: groupSchemas,
|
||||||
|
computedAttributes: groupComputed,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 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);
|
||||||
|
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
return R.success(res, 'Field values retrieved.', rows.map((r) => r.value).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await mdl_UserGroups.findAll({
|
||||||
|
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']],
|
||||||
|
where: { [field]: { [Op.ne]: null } },
|
||||||
|
paranoid: false,
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── MEMBERSHIP ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getUsersNotInGroup = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { gid: group_id } = req.params;
|
||||||
|
|
||||||
|
const members = await mdl_UserGroupMembers.findAll({ where: { group_id }, attributes: ['user_id'] });
|
||||||
|
const memberIds = members.map((m) => m.user_id);
|
||||||
|
|
||||||
|
const users = await mdl_Users.findAll({
|
||||||
|
where: { user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] } },
|
||||||
|
attributes: [
|
||||||
|
'user_id',
|
||||||
|
[Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return R.success(res, 'Users fetched.', users);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET USERS NOT IN GROUP]', err);
|
||||||
|
return R.error(res, 'Could not fetch users.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getUsersInGroup = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { gid: group_id } = req.params;
|
||||||
|
|
||||||
|
const group = await mdl_UserGroups.findByPk(group_id, {
|
||||||
|
include: [{
|
||||||
|
model: mdl_Users,
|
||||||
|
as: 'members',
|
||||||
|
through: { attributes: [] },
|
||||||
|
attributes: [
|
||||||
|
'user_id',
|
||||||
|
[Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!group) return R.error(res, 'Group not found.', 404);
|
||||||
|
return R.success(res, 'Group members fetched.', group.members);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET USERS IN GROUP]', err);
|
||||||
|
return R.error(res, 'Could not fetch group members.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.addUserToGroup = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { gid: group_id } = req.params;
|
||||||
|
const { user_ids } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(user_ids) || !user_ids.length)
|
||||||
|
return R.error(res, 'No users provided.', 400);
|
||||||
|
|
||||||
|
const existingUsers = await mdl_Users.findAll({ where: { user_id: user_ids }, attributes: ['user_id'] });
|
||||||
|
const existingIds = existingUsers.map((u) => u.user_id);
|
||||||
|
const notFound = user_ids.filter((id) => !existingIds.includes(id));
|
||||||
|
|
||||||
|
if (notFound.length)
|
||||||
|
return R.error(res, `Users not found: ${notFound.join(', ')}`, 404);
|
||||||
|
|
||||||
|
await mdl_UserGroupMembers.restore({ where: { user_id: user_ids, group_id } });
|
||||||
|
await mdl_UserGroupMembers.update(
|
||||||
|
{ deletedBy: null, updatedBy: req.user.user_id },
|
||||||
|
{ where: { user_id: user_ids, group_id }, paranoid: false }
|
||||||
|
);
|
||||||
|
await mdl_UserGroupMembers.bulkCreate(
|
||||||
|
user_ids.map((user_id) => ({ user_id, group_id, createdBy: req.user.user_id })),
|
||||||
|
{ ignoreDuplicates: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return R.success(res, 'Users added to group.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][ADD USERS TO GROUP]', err);
|
||||||
|
return R.error(res, 'Could not add users to group.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.removeUserFromGroup = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { gid: group_id } = req.params;
|
||||||
|
const { user_ids } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(user_ids) || !user_ids.length)
|
||||||
|
return R.error(res, 'No users provided.', 400);
|
||||||
|
|
||||||
|
const existingMembers = await mdl_UserGroupMembers.findAll({
|
||||||
|
where: { user_id: user_ids, group_id }, attributes: ['user_id'],
|
||||||
|
});
|
||||||
|
const existingIds = existingMembers.map((m) => m.user_id);
|
||||||
|
const notFound = user_ids.filter((id) => !existingIds.includes(id));
|
||||||
|
|
||||||
|
if (notFound.length)
|
||||||
|
return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404);
|
||||||
|
|
||||||
|
await mdl_UserGroupMembers.update(
|
||||||
|
{ deletedBy: req.user.user_id },
|
||||||
|
{ where: { user_id: user_ids, group_id } }
|
||||||
|
);
|
||||||
|
await mdl_UserGroupMembers.destroy({ where: { user_id: user_ids, group_id } });
|
||||||
|
|
||||||
|
return R.success(res, 'Users removed from group.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][REMOVE USERS FROM GROUP]', err);
|
||||||
|
return R.error(res, 'Could not remove users from group.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,50 +2,38 @@
|
|||||||
* File Name: users.controller.js (admin)
|
* File Name: users.controller.js (admin)
|
||||||
* Type of Program: Controller
|
* Type of Program: Controller
|
||||||
* Description: Admin-level user management — full CRUD on any user.
|
* Description: Admin-level user management — full CRUD on any user.
|
||||||
* Admins can: list all users, view any user, update acc_type,
|
|
||||||
* activate/deactivate, hard-delete, manage groups.
|
|
||||||
*
|
|
||||||
* Endpoints (require authenticate → requireAdmin()):
|
|
||||||
* GET /api/admin/users → all users (paginated)
|
|
||||||
* GET /api/admin/users/:id → any user
|
|
||||||
* PUT /api/admin/users/:id → update acc_type, is_active, personal_info
|
|
||||||
* DELETE /api/admin/users/:id → hard delete
|
|
||||||
* POST /api/admin/users/:id/groups/:gid → add user to group
|
|
||||||
* DELETE /api/admin/users/:id/groups/:gid → remove user from group
|
|
||||||
* GET /api/admin/groups → list groups
|
|
||||||
* POST /api/admin/groups → create group
|
|
||||||
* DELETE /api/admin/groups/:gid → delete group
|
|
||||||
*
|
*
|
||||||
* Author: rgrgogu
|
* Author: rgrgogu
|
||||||
* Date Created: Oct. 6, 2025
|
* Date Created: Oct. 6, 2025
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const sequelize = require('../../config/db.config')
|
const sequelize = require('../../config/db.config');
|
||||||
const { Op, Sequelize } = require('sequelize')
|
const { Op, Sequelize } = require('sequelize');
|
||||||
const bcrypt = require('bcryptjs')
|
const bcrypt = require('bcryptjs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const mdl_Users = require('../../models/users/users.mdl');
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
|
|
||||||
const sendEmail = require('../../services/email.service');
|
const sendEmail = require('../../services/email.service');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { paginate, auditInclude } = require('../../utils/paginate.util');
|
const { paginate } = require('../../utils/paginate.util');
|
||||||
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
||||||
|
|
||||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: usersComputed } = require('../../models/users/users.attributes');
|
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = 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', 'must_change_password', 'password_expires_at'];
|
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at'];
|
||||||
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
|
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
|
||||||
|
|
||||||
|
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getUsers = async (req, res) => {
|
exports.getUsers = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await paginate(mdl_Users, req, {
|
const result = await paginate(mdl_Users, req, {
|
||||||
excludeAttributes: usersExclude,
|
excludeAttributes: usersExclude,
|
||||||
jsonbSchemas: usersSchemas,
|
jsonbSchemas: usersSchemas,
|
||||||
jsonbColumn: 'personal_info',
|
jsonbColumn: 'personal_info',
|
||||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, 'Users retrieved.', result);
|
return R.success(res, 'Users retrieved.', result);
|
||||||
@@ -55,29 +43,35 @@ exports.getUsers = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── GET single user ───────────────────────────────────────────────────────────
|
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getUser = async (req, res) => {
|
exports.getUser = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await mdl_Users.findByPk(req.params.id, {
|
const { id } = req.params;
|
||||||
|
if (!id || id === 'undefined') return R.error(res, 'Invalid User ID.', 400);
|
||||||
|
|
||||||
|
const user = await mdl_Users.findByPk(id, {
|
||||||
attributes: { exclude: EXCLUDED },
|
attributes: { exclude: EXCLUDED },
|
||||||
include: [{
|
include: [{
|
||||||
model: mdl_UserGroups,
|
model: mdl_UserGroups,
|
||||||
as: 'groups', // ← matches the association alias
|
as: 'groups',
|
||||||
through: { attributes: [] }
|
through: { attributes: [] },
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return R.error(res, 'User not found.', 404);
|
if (!user) return R.error(res, 'User not found.', 404);
|
||||||
return R.success(res, 'User retrieved.', user);
|
return R.success(res, 'User retrieved.', user);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ADMIN][GET USER]", err);
|
console.error('[ADMIN][GET USER]', err);
|
||||||
return R.error(res, 'Could not retrieve user.', 500);
|
return R.error(res, 'Could not retrieve user.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── POST add staff user ───────────────────────────────────────────────────────
|
// ─── ADD STAFF ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.addStaffUser = async (req, res) => {
|
exports.addStaffUser = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, personal_info = {} } = req.body; // ← password removed from body
|
const { email, personal_info = {} } = req.body;
|
||||||
|
|
||||||
if (!email)
|
if (!email)
|
||||||
return R.error(res, 'Email is required.', 400);
|
return R.error(res, 'Email is required.', 400);
|
||||||
@@ -86,44 +80,36 @@ exports.addStaffUser = async (req, res) => {
|
|||||||
return R.error(res, 'First name and last name are required.', 400);
|
return R.error(res, 'First name and last name are required.', 400);
|
||||||
|
|
||||||
const existing = await mdl_Users.findOne({ where: { email } });
|
const existing = await mdl_Users.findOne({ where: { email } });
|
||||||
if (existing)
|
if (existing) return R.error(res, 'Email is already in use.', 409);
|
||||||
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 plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12);
|
const hashed = await bcrypt.hash(plainPassword, 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 passwordExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
const enriched = enrichPersonalInfo(personal_info);
|
||||||
const enriched = enrichPersonalInfo(personal_info);
|
const fullName = enriched?.name?.full_name ?? email;
|
||||||
const fullName = enriched?.name?.full_name ?? email;
|
|
||||||
|
|
||||||
const user = await mdl_Users.create({
|
const user = await mdl_Users.create({
|
||||||
email,
|
email,
|
||||||
password: hashed,
|
password: hashed,
|
||||||
password_expires_at: passwordExpiresAt, // ← requires column in DB
|
password_expires_at: passwordExpiresAt,
|
||||||
must_change_password: true, // ← force change on first login
|
must_change_password: true,
|
||||||
acc_type: 'staff',
|
acc_type: 'staff',
|
||||||
reg_type: 'system',
|
reg_type: 'system',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_verified: true,
|
is_verified: true,
|
||||||
createdBy: req.user.user_id,
|
createdBy: req.user.user_id,
|
||||||
personal_info: enriched,
|
personal_info: enriched,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: email, type: "ADD_STAFF", data: {
|
to: email, type: 'ADD_STAFF', data: {
|
||||||
name: fullName,
|
name: fullName, email, password: plainPassword, expiryHours: 24,
|
||||||
email,
|
},
|
||||||
password: plainPassword,
|
|
||||||
expiryHours: 24,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, 'Staff user created successfully.', {
|
return R.success(res, 'Staff user created successfully.', {
|
||||||
user_id: user.user_id,
|
user_id: user.user_id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
acc_type: user.acc_type,
|
acc_type: user.acc_type,
|
||||||
}, 201);
|
}, 201);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -132,7 +118,8 @@ exports.addStaffUser = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── PUT update any user ───────────────────────────────────────────────────────
|
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.updateUser = async (req, res) => {
|
exports.updateUser = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await mdl_Users.findByPk(req.params.id);
|
const user = await mdl_Users.findByPk(req.params.id);
|
||||||
@@ -145,23 +132,22 @@ exports.updateUser = async (req, res) => {
|
|||||||
if (Number(req.params.id) === req.user.user_id && updates.acc_type !== undefined)
|
if (Number(req.params.id) === req.user.user_id && updates.acc_type !== undefined)
|
||||||
return R.error(res, 'Admins cannot change their own role.', 400);
|
return R.error(res, 'Admins cannot change their own role.', 400);
|
||||||
|
|
||||||
// Enrich personal_info before saving
|
if (updates.personal_info)
|
||||||
if (updates.personal_info) {
|
|
||||||
updates.personal_info = enrichPersonalInfo(updates.personal_info);
|
updates.personal_info = enrichPersonalInfo(updates.personal_info);
|
||||||
}
|
|
||||||
|
|
||||||
updates.updatedBy = req.user.user_id; // ← who updated
|
updates.updatedBy = req.user.user_id;
|
||||||
|
|
||||||
await user.update(updates);
|
await user.update(updates);
|
||||||
const updated = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED } });
|
const updated = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED } });
|
||||||
return R.success(res, 'User updated.', updated);
|
return R.success(res, 'User updated.', updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ADMIN][UPDATE USER]", err);
|
console.error('[ADMIN][UPDATE USER]', err);
|
||||||
return R.error(res, 'Could not update user.', 500);
|
return R.error(res, 'Could not update user.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── DEACTIVATE user (soft delete) ────────────────────────────────────────────
|
// ─── DEACTIVATE (soft delete) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.deactivateUser = async (req, res) => {
|
exports.deactivateUser = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (Number(req.params.id) === req.user.user_id)
|
if (Number(req.params.id) === req.user.user_id)
|
||||||
@@ -172,11 +158,7 @@ exports.deactivateUser = async (req, res) => {
|
|||||||
if (!user.is_active && user.deletedAt)
|
if (!user.is_active && user.deletedAt)
|
||||||
return R.error(res, 'User is already deactivated.', 400);
|
return R.error(res, 'User is already deactivated.', 400);
|
||||||
|
|
||||||
await user.update({
|
await user.update({ is_active: false, deletedBy: req.user.user_id });
|
||||||
is_active: false,
|
|
||||||
deletedBy: req.user.user_id, // ← who deactivated
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.destroy();
|
await user.destroy();
|
||||||
|
|
||||||
await mdl_UserSessions.update(
|
await mdl_UserSessions.update(
|
||||||
@@ -186,121 +168,89 @@ exports.deactivateUser = async (req, res) => {
|
|||||||
|
|
||||||
return R.success(res, 'User deactivated successfully.');
|
return R.success(res, 'User deactivated successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ADMIN][DEACTIVATE USER]", err);
|
console.error('[ADMIN][DEACTIVATE USER]', err);
|
||||||
return R.error(res, 'Could not deactivate user.', 500);
|
return R.error(res, 'Could not deactivate user.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── BULK DEACTIVATE users ────────────────────────────────────────────────────
|
// ─── BULK DEACTIVATE ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.bulkDeactivateUsers = async (req, res) => {
|
exports.bulkDeactivateUsers = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { ids } = req.body; // expects { ids: [1, 2, 3] }
|
const { ids } = req.body;
|
||||||
|
if (!Array.isArray(ids) || !ids.length)
|
||||||
if (!Array.isArray(ids) || ids.length === 0)
|
|
||||||
return R.error(res, 'No user IDs provided.', 400);
|
return R.error(res, 'No user IDs provided.', 400);
|
||||||
|
|
||||||
if (ids.includes(req.user.user_id))
|
if (ids.includes(req.user.user_id))
|
||||||
return R.error(res, 'You cannot deactivate your own account.', 400);
|
return R.error(res, 'You cannot deactivate your own account.', 400);
|
||||||
|
|
||||||
const users = await mdl_Users.findAll({
|
const users = await mdl_Users.findAll({ where: { user_id: ids } });
|
||||||
where: { user_id: ids },
|
if (!users.length) return R.error(res, 'No users found.', 404);
|
||||||
});
|
|
||||||
|
|
||||||
if (!users.length)
|
|
||||||
return R.error(res, 'No users found.', 404);
|
|
||||||
|
|
||||||
// Filter only currently active users — skip already-deactivated ones
|
|
||||||
const activeUsers = users.filter((u) => u.is_active && !u.deletedAt);
|
const activeUsers = users.filter((u) => u.is_active && !u.deletedAt);
|
||||||
|
|
||||||
if (!activeUsers.length)
|
if (!activeUsers.length)
|
||||||
return R.error(res, 'All selected users are already deactivated.', 400);
|
return R.error(res, 'All selected users are already deactivated.', 400);
|
||||||
|
|
||||||
const activeIds = activeUsers.map((u) => u.user_id);
|
const activeIds = activeUsers.map((u) => u.user_id);
|
||||||
|
|
||||||
// Soft-delete + mark who deactivated them
|
|
||||||
await mdl_Users.update(
|
await mdl_Users.update(
|
||||||
{ is_active: false, deletedBy: req.user.user_id },
|
{ is_active: false, deletedBy: req.user.user_id },
|
||||||
{ where: { user_id: activeIds } }
|
{ where: { user_id: activeIds } }
|
||||||
);
|
);
|
||||||
|
await mdl_Users.destroy({ where: { user_id: activeIds } });
|
||||||
// Sequelize paranoid bulk destroy (sets deletedAt)
|
|
||||||
await mdl_Users.destroy({
|
|
||||||
where: { user_id: activeIds },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Force logout all their active sessions
|
|
||||||
await mdl_UserSessions.update(
|
await mdl_UserSessions.update(
|
||||||
{
|
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||||
is_active: false,
|
|
||||||
logout_info: {
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
forced_by: req.user.user_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ where: { user_id: activeIds } }
|
{ where: { user_id: activeIds } }
|
||||||
);
|
);
|
||||||
|
|
||||||
return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, {
|
return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, {
|
||||||
deactivated_ids: activeIds,
|
deactivated_ids: activeIds,
|
||||||
// Inform caller if some were already inactive and got skipped
|
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ADMIN][BULK DEACTIVATE USERS]", err);
|
console.error('[ADMIN][BULK DEACTIVATE USERS]', err);
|
||||||
return R.error(res, 'Could not deactivate users.', 500);
|
return R.error(res, 'Could not deactivate users.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── RESTORE user ──────────────────────────────────────────────────────────────
|
// ─── RESTORE ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.restoreUser = async (req, res) => {
|
exports.restoreUser = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await mdl_Users.findOne({
|
const user = await mdl_Users.findOne({
|
||||||
where: { user_id: req.params.id },
|
where: { user_id: req.params.id }, paranoid: false,
|
||||||
paranoid: false,
|
|
||||||
});
|
});
|
||||||
if (!user) return R.error(res, 'User not found.', 404);
|
if (!user) return R.error(res, 'User not found.', 404);
|
||||||
if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400);
|
if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400);
|
||||||
|
|
||||||
await user.restore();
|
await user.restore();
|
||||||
await user.update({
|
await user.update({ is_active: true, updatedBy: req.user.user_id, deletedBy: null });
|
||||||
is_active: true,
|
|
||||||
updatedBy: req.user.user_id, // ← who restored
|
|
||||||
deletedBy: null
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'User restored successfully.');
|
return R.success(res, 'User restored successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ADMIN][RESTORE USER]", err);
|
console.error('[ADMIN][RESTORE USER]', err);
|
||||||
return R.error(res, 'Could not restore user.', 500);
|
return R.error(res, 'Could not restore user.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── BULK RESTORE users ────────────────────────────────────────────────────────
|
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.bulkRestoreUsers = async (req, res) => {
|
exports.bulkRestoreUsers = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { ids } = req.body;
|
const { ids } = req.body;
|
||||||
|
if (!Array.isArray(ids) || !ids.length)
|
||||||
if (!Array.isArray(ids) || ids.length === 0)
|
|
||||||
return R.error(res, 'No user IDs provided.', 400);
|
return R.error(res, 'No user IDs provided.', 400);
|
||||||
|
|
||||||
const users = await mdl_Users.findAll({
|
const users = await mdl_Users.findAll({ where: { user_id: ids }, paranoid: false });
|
||||||
where: { user_id: ids },
|
if (!users.length) return R.error(res, 'No users found.', 404);
|
||||||
paranoid: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!users.length)
|
|
||||||
return R.error(res, 'No users found.', 404);
|
|
||||||
|
|
||||||
// Filter only soft-deleted users — skip already-active ones
|
|
||||||
const deletedUsers = users.filter((u) => u.deletedAt);
|
const deletedUsers = users.filter((u) => u.deletedAt);
|
||||||
|
|
||||||
if (!deletedUsers.length)
|
if (!deletedUsers.length)
|
||||||
return R.error(res, 'All selected users are already active.', 400);
|
return R.error(res, 'All selected users are already active.', 400);
|
||||||
|
|
||||||
const deletedIds = deletedUsers.map((u) => u.user_id);
|
const deletedIds = deletedUsers.map((u) => u.user_id);
|
||||||
|
|
||||||
await mdl_Users.restore({ where: { user_id: deletedIds } });
|
await mdl_Users.restore({ where: { user_id: deletedIds } });
|
||||||
|
|
||||||
await mdl_Users.update(
|
await mdl_Users.update(
|
||||||
{ is_active: true, updatedBy: req.user.user_id, deletedBy: null },
|
{ is_active: true, updatedBy: req.user.user_id, deletedBy: null },
|
||||||
{ where: { user_id: deletedIds }, paranoid: false }
|
{ where: { user_id: deletedIds }, paranoid: false }
|
||||||
@@ -308,278 +258,26 @@ exports.bulkRestoreUsers = async (req, res) => {
|
|||||||
|
|
||||||
return R.success(res, `${deletedIds.length} user(s) restored successfully.`, {
|
return R.success(res, `${deletedIds.length} user(s) restored successfully.`, {
|
||||||
restored_ids: deletedIds,
|
restored_ids: deletedIds,
|
||||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ADMIN][BULK RESTORE USERS]", err);
|
console.error('[ADMIN][BULK RESTORE USERS]', err);
|
||||||
return R.error(res, 'Could not restore users.', 500);
|
return R.error(res, 'Could not restore users.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getGroups = async (req, res) => {
|
// ─── SESSIONS ─────────────────────────────────────────────────────────────────
|
||||||
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' },
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'Groups retrieved.', result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][GET GROUPS]', err);
|
|
||||||
return R.error(res, 'Could not retrieve groups.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.getGroup = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
|
||||||
if (!group) return R.error(res, 'Group not found.', 404);
|
|
||||||
|
|
||||||
const members = await paginate(mdl_Users, req, {
|
|
||||||
excludeAttributes: groupExclude,
|
|
||||||
jsonbSchemas: groupSchemas,
|
|
||||||
computedAttributes: groupComputed,
|
|
||||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
|
||||||
findOptions: {
|
|
||||||
paranoid: false,
|
|
||||||
where: {
|
|
||||||
deletedAt: { [Op.ne]: null },
|
|
||||||
is_active: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'Group retrieved.', { group, members });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][GET GROUP]', err);
|
|
||||||
return R.error(res, 'Could not retrieve group.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.createGroup = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { name, description } = req.body;
|
|
||||||
if (!name) return R.error(res, 'Group name is required.', 400);
|
|
||||||
|
|
||||||
const group = await mdl_UserGroups.create({
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
createdBy: req.user.user_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'Group created.', group, 201);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][CREATE GROUP]', err);
|
|
||||||
return R.error(res, 'Could not create group.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.updateGroup = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { name, description } = req.body;
|
|
||||||
|
|
||||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
|
||||||
if (!group) return R.error(res, 'Group not found.', 404);
|
|
||||||
|
|
||||||
if (name !== undefined) group.name = name;
|
|
||||||
if (description !== undefined) group.description = description;
|
|
||||||
group.updatedBy = req.user.user_id;
|
|
||||||
await group.save();
|
|
||||||
|
|
||||||
return R.success(res, 'Group updated.', group);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][UPDATE GROUP]', err);
|
|
||||||
return R.error(res, 'Could not update group.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.deactivateGroup = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
|
||||||
if (!group) return R.error(res, 'Group not found.', 404);
|
|
||||||
if (!group.is_active) return R.error(res, 'Group is already deactivated.', 400);
|
|
||||||
|
|
||||||
await group.update({
|
|
||||||
is_active: false,
|
|
||||||
updatedBy: req.user.user_id,
|
|
||||||
deletedBy: req.user.user_id,
|
|
||||||
});
|
|
||||||
await group.destroy();
|
|
||||||
|
|
||||||
return R.success(res, 'Group deactivated.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][DEACTIVATE GROUP]', err);
|
|
||||||
return R.error(res, 'Could not deactivate group.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.restoreGroup = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const group = await mdl_UserGroups.findOne({
|
|
||||||
where: { group_id: req.params.gid },
|
|
||||||
paranoid: false, // ← needed to find soft-deleted rows
|
|
||||||
});
|
|
||||||
if (!group) return R.error(res, 'Group not found.', 404);
|
|
||||||
if (group.is_active) return R.error(res, 'Group is already active.', 400);
|
|
||||||
|
|
||||||
await group.restore();
|
|
||||||
await group.update({
|
|
||||||
is_active: true,
|
|
||||||
updatedBy: req.user.user_id,
|
|
||||||
deletedBy: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'Group restored.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][RESTORE GROUP]', err);
|
|
||||||
return R.error(res, 'Could not restore group.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Group membership ──────────────────────────────────────────────────────────
|
|
||||||
// Get List of Members in a Group, to be added
|
|
||||||
exports.getUsersNotInGroup = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { gid: group_id } = req.params;
|
|
||||||
|
|
||||||
const members = await mdl_UserGroupMembers.findAll({
|
|
||||||
where: { group_id },
|
|
||||||
attributes: ['user_id'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberIds = members.map(m => m.user_id);
|
|
||||||
|
|
||||||
const users = await mdl_Users.findAll({
|
|
||||||
where: {
|
|
||||||
user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] },
|
|
||||||
},
|
|
||||||
attributes: [
|
|
||||||
'user_id',
|
|
||||||
[Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'Users fetched.', users);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][GET USERS NOT IN GROUP]', err);
|
|
||||||
return R.error(res, 'Could not fetch users.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get List of Members in a Group, to be removed
|
|
||||||
exports.getUsersInGroup = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { gid: group_id } = req.params;
|
|
||||||
|
|
||||||
const group = await mdl_UserGroups.findByPk(group_id, {
|
|
||||||
include: [{
|
|
||||||
model: mdl_Users,
|
|
||||||
as: 'members',
|
|
||||||
through: { attributes: [] },
|
|
||||||
attributes: [
|
|
||||||
'user_id',
|
|
||||||
[Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!group) return R.error(res, 'Group not found.', 404);
|
|
||||||
|
|
||||||
return R.success(res, 'Group members fetched.', group.members);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][GET USERS IN GROUP]', err);
|
|
||||||
return R.error(res, 'Could not fetch group members.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.addUserToGroup = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { gid: group_id } = req.params;
|
|
||||||
const { user_ids } = req.body;
|
|
||||||
|
|
||||||
if (!Array.isArray(user_ids) || user_ids.length === 0)
|
|
||||||
return R.error(res, 'No users provided.', 400);
|
|
||||||
|
|
||||||
const existingUsers = await mdl_Users.findAll({
|
|
||||||
where: { user_id: user_ids },
|
|
||||||
attributes: ['user_id'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingUserIds = existingUsers.map(u => u.user_id);
|
|
||||||
const notFound = user_ids.filter(id => !existingUserIds.includes(id));
|
|
||||||
|
|
||||||
if (notFound.length > 0)
|
|
||||||
return R.error(res, `Users not found: ${notFound.join(', ')}`, 404);
|
|
||||||
|
|
||||||
// Restore soft-deleted rows
|
|
||||||
await mdl_UserGroupMembers.restore({
|
|
||||||
where: { user_id: user_ids, group_id },
|
|
||||||
});
|
|
||||||
|
|
||||||
await mdl_UserGroupMembers.update(
|
|
||||||
{ deletedBy: null, updatedBy: req.user.user_id },
|
|
||||||
{ where: { user_id: user_ids, group_id }, paranoid: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert any that didn't exist at all
|
|
||||||
await mdl_UserGroupMembers.bulkCreate(
|
|
||||||
user_ids.map(user_id => ({ user_id, group_id, createdBy: req.user.user_id })),
|
|
||||||
{ ignoreDuplicates: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
return R.success(res, 'Users added to group.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][ADD USERS TO GROUP]', err);
|
|
||||||
return R.error(res, 'Could not add users to group.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.removeUserFromGroup = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { gid: group_id } = req.params;
|
|
||||||
const { user_ids } = req.body;
|
|
||||||
|
|
||||||
if (!Array.isArray(user_ids) || user_ids.length === 0)
|
|
||||||
return R.error(res, 'No users provided.', 400);
|
|
||||||
|
|
||||||
const existingMembers = await mdl_UserGroupMembers.findAll({
|
|
||||||
where: { user_id: user_ids, group_id },
|
|
||||||
attributes: ['user_id'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingMemberIds = existingMembers.map(m => m.user_id);
|
|
||||||
const notFound = user_ids.filter(id => !existingMemberIds.includes(id));
|
|
||||||
|
|
||||||
if (notFound.length > 0)
|
|
||||||
return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404);
|
|
||||||
|
|
||||||
await mdl_UserGroupMembers.update(
|
|
||||||
{ deletedBy: req.user.user_id },
|
|
||||||
{ where: { user_id: user_ids, group_id } }
|
|
||||||
);
|
|
||||||
await mdl_UserGroupMembers.destroy({
|
|
||||||
where: { user_id: user_ids, group_id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'Users removed from group.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][REMOVE USERS FROM GROUP]', err);
|
|
||||||
return R.error(res, 'Could not remove users from group.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Admin sessions management ─────────────────────────────────────────────────
|
|
||||||
exports.getUserSessions = async (req, res) => {
|
exports.getUserSessions = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sessions = await mdl_UserSessions.findAll({
|
const sessions = await mdl_UserSessions.findAll({
|
||||||
where: { user_id: req.params.id },
|
where: { user_id: req.params.id },
|
||||||
attributes: { exclude: ['refresh_token_hash'] },
|
attributes: { exclude: ['refresh_token_hash'] },
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
});
|
});
|
||||||
return R.success(res, 'Sessions retrieved.', sessions);
|
return R.success(res, 'Sessions retrieved.', sessions);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET USER SESSIONS]', err);
|
||||||
return R.error(res, 'Could not retrieve sessions.', 500);
|
return R.error(res, 'Could not retrieve sessions.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -588,105 +286,98 @@ exports.terminateSession = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const session = await mdl_UserSessions.findByPk(req.params.sid);
|
const session = await mdl_UserSessions.findByPk(req.params.sid);
|
||||||
if (!session) return R.error(res, 'Session not found.', 404);
|
if (!session) return R.error(res, 'Session not found.', 404);
|
||||||
await session.update({ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } });
|
|
||||||
|
await session.update({
|
||||||
|
is_active: false,
|
||||||
|
logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id },
|
||||||
|
});
|
||||||
|
|
||||||
return R.success(res, 'Session terminated.');
|
return R.success(res, 'Session terminated.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][TERMINATE SESSION]', err);
|
||||||
return R.error(res, 'Could not terminate session.', 500);
|
return R.error(res, 'Could not terminate session.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── FIELD VALUES ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getUserFieldValues = async (req, res) => {
|
exports.getUserFieldValues = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { field } = req.query;
|
const { field } = req.query;
|
||||||
if (!field) return R.error(res, 'Field is required.', 400);
|
if (!field) return R.error(res, 'Field is required.', 400);
|
||||||
|
|
||||||
const allowedFields = Object.keys(mdl_Users.rawAttributes).filter(
|
const allowedFields = Object.keys(mdl_Users.rawAttributes).filter(
|
||||||
f => !['password', 'otp_code', 'otp_expires_at', 'personal_info'].includes(f)
|
(f) => !['password', 'otp_code', 'otp_expires_at', 'personal_info'].includes(f)
|
||||||
);
|
);
|
||||||
|
|
||||||
const dateFields = ['createdAt', 'updatedAt', 'deletedAt', 'modifiedAt'];
|
const dateFields = ['createdAt', 'updatedAt', 'deletedAt', 'modifiedAt'];
|
||||||
|
|
||||||
if (!field.includes('.')) {
|
if (!field.includes('.')) {
|
||||||
if (!allowedFields.includes(field))
|
if (!allowedFields.includes(field))
|
||||||
return R.error(res, 'Invalid or restricted field.', 400);
|
return R.error(res, 'Invalid or restricted field.', 400);
|
||||||
|
|
||||||
// ─── Audit By fields — return names instead of IDs ───────────────────
|
|
||||||
if (auditByFields.includes(field)) {
|
if (auditByFields.includes(field)) {
|
||||||
const [rows] = await sequelize.query(`
|
const [rows] = await sequelize.query(`
|
||||||
SELECT DISTINCT u2."personal_info"->'name'->>'full_name' AS value
|
SELECT DISTINCT u2."personal_info"->'name'->>'full_name' AS value
|
||||||
FROM users u1
|
FROM users u1
|
||||||
JOIN users u2 ON u2.user_id = u1."${field}"
|
JOIN users u2 ON u2.user_id = u1."${field}"
|
||||||
WHERE u1."${field}" IS NOT NULL
|
WHERE u1."${field}" IS NOT NULL
|
||||||
AND u2."personal_info"->'name'->>'full_name' IS NOT NULL
|
AND u2."personal_info"->'name'->>'full_name' IS NOT NULL
|
||||||
ORDER BY value ASC
|
ORDER BY value ASC
|
||||||
`);
|
`);
|
||||||
|
return R.success(res, 'Field values retrieved.', rows.map((r) => r.value).filter(Boolean));
|
||||||
const values = rows.map(r => r.value).filter(Boolean);
|
|
||||||
return R.success(res, 'Field values retrieved.', values);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Date fields ──────────────────────────────────────────────────────
|
|
||||||
if (dateFields.includes(field)) {
|
if (dateFields.includes(field)) {
|
||||||
const results = await mdl_Users.findAll({
|
const results = await mdl_Users.findAll({
|
||||||
attributes: [
|
attributes: [[Sequelize.fn('DISTINCT', Sequelize.fn('DATE', Sequelize.col(field))), 'value']],
|
||||||
[Sequelize.fn('DISTINCT', Sequelize.fn('DATE', Sequelize.col(field))), 'value'],
|
|
||||||
],
|
|
||||||
where: { [field]: { [Op.ne]: null } },
|
where: { [field]: { [Op.ne]: null } },
|
||||||
order: [[Sequelize.fn('DATE', Sequelize.col(field)), 'DESC']],
|
order: [[Sequelize.fn('DATE', Sequelize.col(field)), 'DESC']],
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
|
return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean));
|
||||||
const values = results.map(r => r.value).filter(Boolean);
|
|
||||||
return R.success(res, 'Field values retrieved.', values);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Regular fields ───────────────────────────────────────────────────
|
|
||||||
const results = await mdl_Users.findAll({
|
const results = await mdl_Users.findAll({
|
||||||
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']],
|
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']],
|
||||||
where: { [field]: { [Op.ne]: null } },
|
where: { [field]: { [Op.ne]: null } },
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
|
return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort());
|
||||||
const values = results.map(r => r.value).filter(Boolean).sort();
|
|
||||||
return R.success(res, 'Field values retrieved.', values);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── JSONB dot-notation fields ─────────────────────────────────────────
|
// ─── JSONB dot-notation ───────────────────────────────────────────────
|
||||||
const [column, ...pathParts] = field.split('.');
|
const [column, ...pathParts] = field.split('.');
|
||||||
const keys = [...pathParts];
|
const keys = [...pathParts];
|
||||||
const lastKey = keys.pop();
|
const lastKey = keys.pop();
|
||||||
const jsonbPath = keys.length
|
const jsonbPath = keys.length
|
||||||
? `"${column}"->${keys.map(k => `'${k}'`).join('->')}->>'${lastKey}'`
|
? `"${column}"->${keys.map((k) => `'${k}'`).join('->')}->>'${lastKey}'`
|
||||||
: `"${column}"->>'${lastKey}'`;
|
: `"${column}"->>'${lastKey}'`;
|
||||||
|
|
||||||
const results = await mdl_Users.findAll({
|
const results = await mdl_Users.findAll({
|
||||||
attributes: [[Sequelize.literal(`DISTINCT ${jsonbPath}`), 'value']],
|
attributes: [[Sequelize.literal(`DISTINCT ${jsonbPath}`), 'value']],
|
||||||
where: Sequelize.literal(`${jsonbPath} IS NOT NULL`),
|
where: Sequelize.literal(`${jsonbPath} IS NOT NULL`),
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const values = results.map(r => r.value).filter(Boolean).sort();
|
return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort());
|
||||||
return R.success(res, 'Field values retrieved.', values);
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][GET USER FIELD VALUES]', err);
|
console.error('[ADMIN][GET USER FIELD VALUES]', err);
|
||||||
return R.error(res, 'Could not retrieve field values.', 500);
|
return R.error(res, 'Could not retrieve field values.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── ARCHIVED ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getArchivedUsers = async (req, res) => {
|
exports.getArchivedUsers = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await paginate(mdl_Users, req, {
|
const result = await paginate(mdl_Users, req, {
|
||||||
excludeAttributes: usersExclude,
|
excludeAttributes: usersExclude,
|
||||||
jsonbSchemas: usersSchemas,
|
jsonbSchemas: usersSchemas,
|
||||||
jsonbColumn: 'personal_info',
|
jsonbColumn: 'personal_info',
|
||||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||||
findOptions: {
|
findOptions: {
|
||||||
paranoid: false, // ← include soft-deleted rows
|
paranoid: false,
|
||||||
where: {
|
where: { deletedAt: { [Op.ne]: null }, is_active: false },
|
||||||
deletedAt: { [Op.ne]: null }, // ← only archived ones
|
|
||||||
is_active: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -695,175 +386,4 @@ exports.getArchivedUsers = async (req, res) => {
|
|||||||
console.error('[ADMIN][GET ARCHIVED USERS]', err);
|
console.error('[ADMIN][GET ARCHIVED USERS]', err);
|
||||||
return R.error(res, 'Could not retrieve archived users.', 500);
|
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// models/Asset.js
|
||||||
|
const { DataTypes } = require("sequelize");
|
||||||
|
const sequelize = require("../../config/db.config");
|
||||||
|
|
||||||
|
const Asset = sequelize.define("Asset", {
|
||||||
|
|
||||||
|
// ─── Identity ─────────────────────────────────────────────────────────────
|
||||||
|
asset_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
||||||
|
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true },
|
||||||
|
|
||||||
|
// ─── File info ────────────────────────────────────────────────────────────
|
||||||
|
original_name: { type: DataTypes.STRING(255), allowNull: false },
|
||||||
|
display_name: { type: DataTypes.STRING(255), allowNull: false },
|
||||||
|
file_url: { type: DataTypes.STRING(512), allowNull: false },
|
||||||
|
file_size: { type: DataTypes.BIGINT, allowNull: false },
|
||||||
|
mime_type: { type: DataTypes.STRING(100), allowNull: false },
|
||||||
|
extension: { type: DataTypes.STRING(20) },
|
||||||
|
checksum: { type: DataTypes.STRING(64) },
|
||||||
|
|
||||||
|
// ─── Classification ───────────────────────────────────────────────────────
|
||||||
|
file_type: {
|
||||||
|
type: DataTypes.ENUM("image", "video", "document", "other"),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: "other",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Image & video dimensions ─────────────────────────────────────────────
|
||||||
|
width: { type: DataTypes.INTEGER },
|
||||||
|
height: { type: DataTypes.INTEGER },
|
||||||
|
|
||||||
|
// ─── Video-specific ───────────────────────────────────────────────────────
|
||||||
|
duration: { type: DataTypes.FLOAT }, // seconds
|
||||||
|
resolution: { type: DataTypes.STRING(20) }, // "1080p", "720p", "4K"
|
||||||
|
frame_rate: { type: DataTypes.FLOAT }, // fps
|
||||||
|
bitrate: { type: DataTypes.BIGINT }, // bps
|
||||||
|
video_codec: { type: DataTypes.STRING(50) }, // "H.264", "H.265"
|
||||||
|
audio_codec: { type: DataTypes.STRING(50) }, // "AAC", "MP3"
|
||||||
|
thumbnail_url: { type: DataTypes.STRING(512) }, // face of the video / doc preview
|
||||||
|
|
||||||
|
// ─── Description ──────────────────────────────────────────────────────────
|
||||||
|
description: { type: DataTypes.TEXT },
|
||||||
|
|
||||||
|
// ─── Storage ──────────────────────────────────────────────────────────────
|
||||||
|
storage_provider: {
|
||||||
|
type: DataTypes.ENUM("local", "s3", "gcs", "cloudinary", "chibisafe", "other"),
|
||||||
|
defaultValue: "local",
|
||||||
|
},
|
||||||
|
storage_bucket: { type: DataTypes.STRING(255) },
|
||||||
|
storage_key: { type: DataTypes.STRING(512) },
|
||||||
|
|
||||||
|
// ─── Access control ───────────────────────────────────────────────────────
|
||||||
|
is_public: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
access_level: {
|
||||||
|
type: DataTypes.ENUM("public", "private", "restricted"),
|
||||||
|
defaultValue: "private",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Polymorphic ownership ────────────────────────────────────────────────
|
||||||
|
owner_type: { type: DataTypes.STRING(100) }, // e.g. "Course", "Channel", "Post", "User"
|
||||||
|
owner_id: { type: DataTypes.BIGINT },
|
||||||
|
|
||||||
|
// ─── Who did what ─────────────────────────────────────────────────────────
|
||||||
|
uploadedBy: { type: DataTypes.BIGINT, allowNull: false },
|
||||||
|
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
|
||||||
|
|
||||||
|
// ─── Soft delete ──────────────────────────────────────────────────────────
|
||||||
|
deletedAt: { type: DataTypes.DATE, allowNull: true, defaultValue: null },
|
||||||
|
}, {
|
||||||
|
tableName: "assets",
|
||||||
|
timestamps: true, // createdAt, updatedAt
|
||||||
|
paranoid: false,
|
||||||
|
indexes: [
|
||||||
|
{ fields: ["uuid"] },
|
||||||
|
{ fields: ["owner_type", "owner_id"] },
|
||||||
|
{ fields: ["uploadedBy"] },
|
||||||
|
{ fields: ["file_type"] },
|
||||||
|
{ fields: ["deletedAt"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = Asset;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
const excludeAttributes = [
|
const excludeAttributes = [
|
||||||
"description"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const jsonbSchemas = {
|
const jsonbSchemas = {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const mdl_Users = require('./users.mdl');
|
|||||||
const mdl_UserGroups = sequelize.define('UserGroup', {
|
const mdl_UserGroups = sequelize.define('UserGroup', {
|
||||||
group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Group ID", order: 1, hidden: 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 },
|
name: { type: DataTypes.STRING(50), allowNull: false, label: "Group Name", order: 2 },
|
||||||
description: { type: DataTypes.TEXT, label: "Description", order: 3 },
|
description: { type: DataTypes.TEXT, label: "Description", order: 3, hidden: true },
|
||||||
is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 4 },
|
is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 4 },
|
||||||
|
|
||||||
// ── Audit trails ────────────────────────────────────────────────────────────
|
// ── Audit trails ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Generated
+136
@@ -19,9 +19,11 @@
|
|||||||
"express-validator": "^7.0.1",
|
"express-validator": "^7.0.1",
|
||||||
"google-auth-library": "^9.0.0",
|
"google-auth-library": "^9.0.0",
|
||||||
"jsonwebtoken": "^9.0.1",
|
"jsonwebtoken": "^9.0.1",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"nodemailer": "^6.9.5",
|
"nodemailer": "^6.9.5",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"path": "^0.12.7",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"rate-limit-redis": "^4.0.0",
|
"rate-limit-redis": "^4.0.0",
|
||||||
@@ -159,6 +161,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -303,6 +311,23 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -375,6 +400,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -1389,6 +1429,25 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multer": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"type-is": "^1.6.18"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -1606,6 +1665,16 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path": {
|
||||||
|
"version": "0.12.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
|
||||||
|
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"process": "^0.11.1",
|
||||||
|
"util": "^0.10.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
@@ -1771,6 +1840,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process": {
|
||||||
|
"version": "0.11.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -1851,6 +1929,20 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -2189,6 +2281,23 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
@@ -2268,6 +2377,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uid-safe": {
|
"node_modules/uid-safe": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
@@ -2314,6 +2429,27 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util": {
|
||||||
|
"version": "0.10.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
|
||||||
|
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/util/node_modules/inherits": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
|||||||
+3
-1
@@ -19,9 +19,11 @@
|
|||||||
"express-validator": "^7.0.1",
|
"express-validator": "^7.0.1",
|
||||||
"google-auth-library": "^9.0.0",
|
"google-auth-library": "^9.0.0",
|
||||||
"jsonwebtoken": "^9.0.1",
|
"jsonwebtoken": "^9.0.1",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"nodemailer": "^6.9.5",
|
"nodemailer": "^6.9.5",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"path": "^0.12.7",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"rate-limit-redis": "^4.0.0",
|
"rate-limit-redis": "^4.0.0",
|
||||||
@@ -32,4 +34,4 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,69 +4,35 @@
|
|||||||
* Description: Protected routes accessible by admin only — full control.
|
* Description: Protected routes accessible by admin only — full control.
|
||||||
*
|
*
|
||||||
* Route Map:
|
* Route Map:
|
||||||
* Users:
|
* Dashboard : GET /api/admin/dashboard
|
||||||
* GET /api/admin/users → all users
|
* Users : /api/admin/users/*
|
||||||
* GET /api/admin/users/:id → single user with groups
|
* Groups : /api/admin/groups/*
|
||||||
* PUT /api/admin/users/:id → update user (acc_type, status, profile)
|
* Assets : /api/admin/assets/*
|
||||||
* DELETE /api/admin/users/:id → hard delete user
|
|
||||||
* GET /api/admin/users/:id/sessions → view all sessions
|
|
||||||
* DELETE /api/admin/users/:id/sessions/:sid → force-terminate a session
|
|
||||||
*
|
*
|
||||||
* Groups:
|
* Guards: authenticate → requireAdmin() → adminLimiter
|
||||||
* GET /api/admin/groups → list groups
|
|
||||||
* POST /api/admin/groups → create group
|
|
||||||
* DELETE /api/admin/groups/:gid → delete group
|
|
||||||
* POST /api/admin/users/:id/groups/:gid → add user to group
|
|
||||||
* DELETE /api/admin/users/:id/groups/:gid → remove user from group
|
|
||||||
*
|
|
||||||
* Guards: authenticate → requireAdmin()
|
|
||||||
*
|
*
|
||||||
* Author: rgrgogu
|
* Author: rgrgogu
|
||||||
* Date Created: Oct. 6, 2025
|
* Date Created: Oct. 6, 2025
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const usersCtrl = require('../../controllers/admin/users.controller');
|
const { authenticate } = require('../../middleware/auth.middleware');
|
||||||
const { authenticate } = require('../../middleware/auth.middleware');
|
const { requireAdmin } = require('../../middleware/rbac.middleware');
|
||||||
const { requireAdmin } = require('../../middleware/rbac.middleware');
|
const { adminLimiter } = require('../../middleware/rateLimiter.middleware');
|
||||||
const { adminLimiter, sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
|
|
||||||
|
|
||||||
// Apply adminLimiter to ALL admin routes
|
const dashboardRoutes = require('./dashboard.routes');
|
||||||
|
const usersRoutes = require('./users.routes');
|
||||||
|
const groupsRoutes = require('./groups.routes');
|
||||||
|
const assetsRoutes = require('./assets.routes');
|
||||||
|
|
||||||
|
// ── Guards — applied to ALL admin routes ──────────────────────────────────────
|
||||||
router.use(authenticate, requireAdmin(), adminLimiter);
|
router.use(authenticate, requireAdmin(), adminLimiter);
|
||||||
|
|
||||||
// ── User management ────────────────────────────────────────────────────────────
|
// ── Mount ─────────────────────────────────────────────────────────────────────
|
||||||
router.post('/users/bulk/restore', sensitiveOpsLimiter, usersCtrl.bulkRestoreUsers); // ← before /:id
|
router.use('/dashboard', dashboardRoutes);
|
||||||
router.delete('/users/bulk', sensitiveOpsLimiter, usersCtrl.bulkDeactivateUsers); // ← before /:id
|
router.use('/users', usersRoutes);
|
||||||
router.get('/users/field-values', usersCtrl.getUserFieldValues); // ← before /:id
|
router.use('/groups', groupsRoutes);
|
||||||
router.get('/users/archived', usersCtrl.getArchivedUsers); // ← before /:id
|
router.use('/assets', assetsRoutes);
|
||||||
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);
|
|
||||||
router.delete('/users/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser); // soft delete
|
|
||||||
router.post('/users/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser); // restore
|
|
||||||
|
|
||||||
// ── Session management ─────────────────────────────────────────────────────────
|
|
||||||
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);
|
|
||||||
router.put('/groups/:gid', sensitiveOpsLimiter, usersCtrl.updateGroup);
|
|
||||||
router.patch('/groups/:gid/deactivate', sensitiveOpsLimiter, usersCtrl.deactivateGroup);
|
|
||||||
router.patch('/groups/:gid/restore', sensitiveOpsLimiter, usersCtrl.restoreGroup);
|
|
||||||
|
|
||||||
// ─── Group membership ──────────────────────────────────────────────────────────
|
|
||||||
router.get('/groups/:gid/users', usersCtrl.getUsersInGroup);
|
|
||||||
router.get('/groups/:gid/users/add', usersCtrl.getUsersNotInGroup);
|
|
||||||
router.post('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.addUserToGroup);
|
|
||||||
router.delete('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.removeUserFromGroup);
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = require('express').Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
|
||||||
|
const controller = require('../../controllers/admin/assets.controller');
|
||||||
|
const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 * 1024 }, // 10GB
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get ('/', controller.getAssets);
|
||||||
|
router.get ('/:assetId', controller.getAsset);
|
||||||
|
router.post ('/upload', upload.single('file'), sensitiveOpsLimiter, controller.uploadAsset);
|
||||||
|
router.put ('/:assetId', sensitiveOpsLimiter, controller.updateAsset);
|
||||||
|
router.patch ('/:assetId/thumbnail', sensitiveOpsLimiter, controller.updateThumbnail);
|
||||||
|
router.delete('/bulk', sensitiveOpsLimiter, controller.deleteAssets);
|
||||||
|
router.delete('/:assetId', sensitiveOpsLimiter, controller.deleteAsset);
|
||||||
|
router.patch ('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const { getUsersDashboard, getGroupsDashboard } = require('../../controllers/admin/dashboard.controller');
|
||||||
|
|
||||||
|
router.get('/users', getUsersDashboard);
|
||||||
|
router.get('/groups', getGroupsDashboard);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const groupsCtrl = require('../../controllers/admin/user_groups.controller');
|
||||||
|
const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
|
||||||
|
|
||||||
|
// ── Groups ────────────────────────────────────────────────────────────────────
|
||||||
|
router.get('/field-values', groupsCtrl.getGroupFieldValues);
|
||||||
|
router.post('/bulk/restore', sensitiveOpsLimiter, groupsCtrl.bulkRestoreGroups);
|
||||||
|
router.delete('/bulk', sensitiveOpsLimiter, groupsCtrl.bulkDeactivateGroups);
|
||||||
|
router.get('/archived', groupsCtrl.getArchivedGroups);
|
||||||
|
router.get('/', groupsCtrl.getGroups);
|
||||||
|
router.get('/:gid', groupsCtrl.getGroup);
|
||||||
|
router.post('/', sensitiveOpsLimiter, groupsCtrl.createGroup);
|
||||||
|
router.put('/:gid', sensitiveOpsLimiter, groupsCtrl.updateGroup);
|
||||||
|
router.patch('/:gid/deactivate', sensitiveOpsLimiter, groupsCtrl.deactivateGroup);
|
||||||
|
router.patch('/:gid/restore', sensitiveOpsLimiter, groupsCtrl.restoreGroup);
|
||||||
|
router.get('/:gid/users', groupsCtrl.getUsersInGroup);
|
||||||
|
router.get('/:gid/users/add', groupsCtrl.getUsersNotInGroup);
|
||||||
|
router.post('/:gid/users', sensitiveOpsLimiter, groupsCtrl.addUserToGroup);
|
||||||
|
router.delete('/:gid/users', sensitiveOpsLimiter, groupsCtrl.removeUserFromGroup);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const usersCtrl = require('../../controllers/admin/users.controller');
|
||||||
|
const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
|
||||||
|
|
||||||
|
router.post('/bulk/restore', sensitiveOpsLimiter, usersCtrl.bulkRestoreUsers);
|
||||||
|
router.delete('/bulk', sensitiveOpsLimiter, usersCtrl.bulkDeactivateUsers);
|
||||||
|
router.get('/field-values', usersCtrl.getUserFieldValues);
|
||||||
|
router.get('/archived', usersCtrl.getArchivedUsers);
|
||||||
|
router.post('/staff', sensitiveOpsLimiter, usersCtrl.addStaffUser);
|
||||||
|
router.get('/', usersCtrl.getUsers);
|
||||||
|
router.get('/:id', usersCtrl.getUser);
|
||||||
|
router.put('/:id', sensitiveOpsLimiter, usersCtrl.updateUser);
|
||||||
|
router.delete('/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser);
|
||||||
|
router.post('/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser);
|
||||||
|
|
||||||
|
// ── Sessions ──────────────────────────────────────────────────────────────────
|
||||||
|
router.get('/:id/sessions', usersCtrl.getUserSessions);
|
||||||
|
router.delete('/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user