This commit is contained in:
rgrgogu
2026-05-09 22:17:24 +08:00
parent 58ccecd28a
commit 4e6017c79b
17 changed files with 2659 additions and 652 deletions
+350
View File
@@ -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." });
}
};
+143
View File
@@ -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.' });
}
};
+380
View File
@@ -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.
+463
View File
@@ -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.
+397
View File
@@ -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);
}
};
+78 -558
View File
@@ -2,26 +2,13 @@
* File Name: users.controller.js (admin)
* Type of Program: Controller
* 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
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const sequelize = require('../../config/db.config')
const { Op, Sequelize } = require('sequelize')
const bcrypt = require('bcryptjs')
const sequelize = require('../../config/db.config');
const { Op, Sequelize } = require('sequelize');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const mdl_Users = require('../../models/users/users.mdl');
@@ -30,15 +17,16 @@ const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/use
const sendEmail = require('../../services/email.service');
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 { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: usersComputed } = require('../../models/users/users.attributes');
const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes');
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at'];
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
// ─── GET ALL ──────────────────────────────────────────────────────────────────
exports.getUsers = async (req, res) => {
try {
const result = await paginate(mdl_Users, req, {
@@ -55,29 +43,35 @@ exports.getUsers = async (req, res) => {
}
};
// ─── GET single user ───────────────────────────────────────────────────────────
// ─── GET ONE ──────────────────────────────────────────────────────────────────
exports.getUser = async (req, res) => {
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 },
include: [{
model: mdl_UserGroups,
as: 'groups', // ← matches the association alias
through: { attributes: [] }
as: 'groups',
through: { attributes: [] },
}],
});
if (!user) return R.error(res, 'User not found.', 404);
return R.success(res, 'User retrieved.', user);
} catch (err) {
console.error("[ADMIN][GET USER]", err);
console.error('[ADMIN][GET USER]', err);
return R.error(res, 'Could not retrieve user.', 500);
}
};
// ─── POST add staff user ───────────────────────────────────────────────────────
// ─── ADD STAFF ────────────────────────────────────────────────────────────────
exports.addStaffUser = async (req, res) => {
try {
const { email, personal_info = {} } = req.body; // ← password removed from body
const { email, personal_info = {} } = req.body;
if (!email)
return R.error(res, 'Email is required.', 400);
@@ -86,24 +80,19 @@ exports.addStaffUser = async (req, res) => {
return R.error(res, 'First name and last name are required.', 400);
const existing = await mdl_Users.findOne({ where: { email } });
if (existing)
return R.error(res, 'Email is already in use.', 409);
if (existing) return R.error(res, 'Email is already in use.', 409);
// ── Auto-generate a temporary password ────────────────────────────────────
const plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12);
const hashed = await bcrypt.hash(plainPassword, 12);
// ── Set password expiry (24 hours from now) ───────────────────────────────
const passwordExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const enriched = enrichPersonalInfo(personal_info);
const fullName = enriched?.name?.full_name ?? email;
const user = await mdl_Users.create({
email,
password: hashed,
password_expires_at: passwordExpiresAt, // ← requires column in DB
must_change_password: true, // ← force change on first login
password_expires_at: passwordExpiresAt,
must_change_password: true,
acc_type: 'staff',
reg_type: 'system',
is_active: true,
@@ -113,12 +102,9 @@ exports.addStaffUser = async (req, res) => {
});
await sendEmail({
to: email, type: "ADD_STAFF", data: {
name: fullName,
email,
password: plainPassword,
expiryHours: 24,
}
to: email, type: 'ADD_STAFF', data: {
name: fullName, email, password: plainPassword, expiryHours: 24,
},
});
return R.success(res, 'Staff user created successfully.', {
@@ -132,7 +118,8 @@ exports.addStaffUser = async (req, res) => {
}
};
// ─── PUT update any user ───────────────────────────────────────────────────────
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateUser = async (req, res) => {
try {
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)
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.updatedBy = req.user.user_id; // ← who updated
updates.updatedBy = req.user.user_id;
await user.update(updates);
const updated = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED } });
return R.success(res, 'User updated.', updated);
} catch (err) {
console.error("[ADMIN][UPDATE USER]", err);
console.error('[ADMIN][UPDATE USER]', err);
return R.error(res, 'Could not update user.', 500);
}
};
// ─── DEACTIVATE user (soft delete) ────────────────────────────────────────────
// ─── DEACTIVATE (soft delete) ─────────────────────────────────────────────────
exports.deactivateUser = async (req, res) => {
try {
if (Number(req.params.id) === req.user.user_id)
@@ -172,11 +158,7 @@ exports.deactivateUser = async (req, res) => {
if (!user.is_active && user.deletedAt)
return R.error(res, 'User is already deactivated.', 400);
await user.update({
is_active: false,
deletedBy: req.user.user_id, // ← who deactivated
});
await user.update({ is_active: false, deletedBy: req.user.user_id });
await user.destroy();
await mdl_UserSessions.update(
@@ -186,121 +168,89 @@ exports.deactivateUser = async (req, res) => {
return R.success(res, 'User deactivated successfully.');
} catch (err) {
console.error("[ADMIN][DEACTIVATE USER]", err);
console.error('[ADMIN][DEACTIVATE USER]', err);
return R.error(res, 'Could not deactivate user.', 500);
}
};
// ─── BULK DEACTIVATE users ────────────────────────────────────────────────────
// ─── BULK DEACTIVATE ──────────────────────────────────────────────────────────
exports.bulkDeactivateUsers = async (req, res) => {
try {
const { ids } = req.body; // expects { ids: [1, 2, 3] }
if (!Array.isArray(ids) || ids.length === 0)
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No user IDs provided.', 400);
if (ids.includes(req.user.user_id))
return R.error(res, 'You cannot deactivate your own account.', 400);
const users = await mdl_Users.findAll({
where: { user_id: ids },
});
const users = await mdl_Users.findAll({ 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);
if (!activeUsers.length)
return R.error(res, 'All selected users are already deactivated.', 400);
const activeIds = activeUsers.map((u) => u.user_id);
// Soft-delete + mark who deactivated them
await mdl_Users.update(
{ is_active: false, deletedBy: req.user.user_id },
{ 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_Users.destroy({ where: { user_id: activeIds } });
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 } }
);
return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, {
deactivated_ids: activeIds,
// Inform caller if some were already inactive and got skipped
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} 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);
}
};
// ─── RESTORE user ──────────────────────────────────────────────────────────────
// ─── RESTORE ──────────────────────────────────────────────────────────────────
exports.restoreUser = async (req, res) => {
try {
const user = await mdl_Users.findOne({
where: { user_id: req.params.id },
paranoid: false,
where: { user_id: req.params.id }, paranoid: false,
});
if (!user) return R.error(res, 'User not found.', 404);
if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400);
await user.restore();
await user.update({
is_active: true,
updatedBy: req.user.user_id, // ← who restored
deletedBy: null
});
await user.update({ is_active: true, updatedBy: req.user.user_id, deletedBy: null });
return R.success(res, 'User restored successfully.');
} catch (err) {
console.error("[ADMIN][RESTORE USER]", err);
console.error('[ADMIN][RESTORE USER]', err);
return R.error(res, 'Could not restore user.', 500);
}
};
// ─── BULK RESTORE users ────────────────────────────────────────────────────────
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
exports.bulkRestoreUsers = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0)
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No user IDs provided.', 400);
const users = await mdl_Users.findAll({
where: { user_id: ids },
paranoid: false,
});
const users = await mdl_Users.findAll({ where: { user_id: ids }, paranoid: false });
if (!users.length) return R.error(res, 'No users found.', 404);
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);
if (!deletedUsers.length)
return R.error(res, 'All selected users are already active.', 400);
const deletedIds = deletedUsers.map((u) => u.user_id);
await mdl_Users.restore({ where: { user_id: deletedIds } });
await mdl_Users.update(
{ is_active: true, updatedBy: req.user.user_id, deletedBy: null },
{ where: { user_id: deletedIds }, paranoid: false }
@@ -311,266 +261,13 @@ exports.bulkRestoreUsers = async (req, res) => {
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
});
} 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);
}
};
exports.getGroups = 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' },
});
// ─── SESSIONS ─────────────────────────────────────────────────────────────────
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) => {
try {
const sessions = await mdl_UserSessions.findAll({
@@ -580,6 +277,7 @@ exports.getUserSessions = async (req, res) => {
});
return R.success(res, 'Sessions retrieved.', sessions);
} catch (err) {
console.error('[ADMIN][GET USER SESSIONS]', err);
return R.error(res, 'Could not retrieve sessions.', 500);
}
};
@@ -588,29 +286,35 @@ exports.terminateSession = async (req, res) => {
try {
const session = await mdl_UserSessions.findByPk(req.params.sid);
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.');
} catch (err) {
console.error('[ADMIN][TERMINATE SESSION]', err);
return R.error(res, 'Could not terminate session.', 500);
}
};
// ─── FIELD VALUES ─────────────────────────────────────────────────────────────
exports.getUserFieldValues = async (req, res) => {
try {
const { field } = req.query;
if (!field) return R.error(res, 'Field is required.', 400);
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'];
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 u2."personal_info"->'name'->>'full_name' AS value
@@ -620,43 +324,33 @@ exports.getUserFieldValues = async (req, res) => {
AND u2."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);
return R.success(res, 'Field values retrieved.', rows.map((r) => r.value).filter(Boolean));
}
// ─── Date fields ──────────────────────────────────────────────────────
if (dateFields.includes(field)) {
const results = await mdl_Users.findAll({
attributes: [
[Sequelize.fn('DISTINCT', Sequelize.fn('DATE', Sequelize.col(field))), 'value'],
],
attributes: [[Sequelize.fn('DISTINCT', Sequelize.fn('DATE', Sequelize.col(field))), 'value']],
where: { [field]: { [Op.ne]: null } },
order: [[Sequelize.fn('DATE', Sequelize.col(field)), 'DESC']],
raw: true,
});
const values = results.map(r => r.value).filter(Boolean);
return R.success(res, 'Field values retrieved.', values);
return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean));
}
// ─── Regular fields ───────────────────────────────────────────────────
const results = await mdl_Users.findAll({
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']],
where: { [field]: { [Op.ne]: null } },
raw: true,
});
const values = results.map(r => r.value).filter(Boolean).sort();
return R.success(res, 'Field values retrieved.', values);
return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort());
}
// ─── JSONB dot-notation fields ─────────────────────────────────────────
// ─── JSONB dot-notation ───────────────────────────────────────────────
const [column, ...pathParts] = field.split('.');
const keys = [...pathParts];
const lastKey = keys.pop();
const jsonbPath = keys.length
? `"${column}"->${keys.map(k => `'${k}'`).join('->')}->>'${lastKey}'`
? `"${column}"->${keys.map((k) => `'${k}'`).join('->')}->>'${lastKey}'`
: `"${column}"->>'${lastKey}'`;
const results = await mdl_Users.findAll({
@@ -665,15 +359,15 @@ exports.getUserFieldValues = async (req, res) => {
raw: true,
});
const values = results.map(r => r.value).filter(Boolean).sort();
return R.success(res, 'Field values retrieved.', values);
return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort());
} catch (err) {
console.error('[ADMIN][GET USER FIELD VALUES]', err);
return R.error(res, 'Could not retrieve field values.', 500);
}
};
// ─── ARCHIVED ─────────────────────────────────────────────────────────────────
exports.getArchivedUsers = async (req, res) => {
try {
const result = await paginate(mdl_Users, req, {
@@ -682,11 +376,8 @@ exports.getArchivedUsers = async (req, res) => {
jsonbColumn: 'personal_info',
auditOptions: { mdl_Users, parentAlias: 'User' },
findOptions: {
paranoid: false, // ← include soft-deleted rows
where: {
deletedAt: { [Op.ne]: null }, // ← only archived ones
is_active: false,
},
paranoid: false,
where: { deletedAt: { [Op.ne]: null }, is_active: false },
},
});
@@ -696,174 +387,3 @@ exports.getArchivedUsers = async (req, res) => {
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);
}
};
+84
View File
@@ -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
View File
@@ -1,5 +1,4 @@
const excludeAttributes = [
"description"
];
const jsonbSchemas = {
+1 -1
View File
@@ -18,7 +18,7 @@ const mdl_Users = require('./users.mdl');
const mdl_UserGroups = sequelize.define('UserGroup', {
group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Group ID", order: 1, hidden: true },
name: { type: DataTypes.STRING(50), allowNull: false, label: "Group Name", order: 2 },
description: { type: DataTypes.TEXT, label: "Description", order: 3 },
description: { type: DataTypes.TEXT, label: "Description", order: 3, hidden: true },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 4 },
// ── Audit trails ────────────────────────────────────────────────────────────
+136
View File
@@ -19,9 +19,11 @@
"express-validator": "^7.0.1",
"google-auth-library": "^9.0.0",
"jsonwebtoken": "^9.0.1",
"multer": "^2.1.1",
"nodemailer": "^6.9.5",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
"path": "^0.12.7",
"pg": "^8.11.3",
"pg-hstore": "^2.3.4",
"rate-limit-redis": "^4.0.0",
@@ -159,6 +161,12 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -303,6 +311,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -375,6 +400,21 @@
"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": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1389,6 +1429,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"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": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1606,6 +1665,16 @@
"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": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -1771,6 +1840,15 @@
"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": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1851,6 +1929,20 @@
"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": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -2189,6 +2281,23 @@
"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": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -2268,6 +2377,12 @@
"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": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
@@ -2314,6 +2429,27 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+2
View File
@@ -19,9 +19,11 @@
"express-validator": "^7.0.1",
"google-auth-library": "^9.0.0",
"jsonwebtoken": "^9.0.1",
"multer": "^2.1.1",
"nodemailer": "^6.9.5",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
"path": "^0.12.7",
"pg": "^8.11.3",
"pg-hstore": "^2.3.4",
"rate-limit-redis": "^4.0.0",
+17 -51
View File
@@ -4,22 +4,12 @@
* Description: Protected routes accessible by admin only — full control.
*
* Route Map:
* Users:
* GET /api/admin/users → all users
* GET /api/admin/users/:id → single user with groups
* PUT /api/admin/users/:id → update user (acc_type, status, profile)
* 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
* Dashboard : GET /api/admin/dashboard
* Users : /api/admin/users/*
* Groups : /api/admin/groups/*
* Assets : /api/admin/assets/*
*
* Groups:
* 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()
* Guards: authenticate → requireAdmin() → adminLimiter
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
@@ -27,46 +17,22 @@
const express = require('express');
const router = express.Router();
const usersCtrl = require('../../controllers/admin/users.controller');
const { authenticate } = require('../../middleware/auth.middleware');
const { requireAdmin } = require('../../middleware/rbac.middleware');
const { adminLimiter, sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
const { adminLimiter } = 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);
// ── User management ────────────────────────────────────────────────────────────
router.post('/users/bulk/restore', sensitiveOpsLimiter, usersCtrl.bulkRestoreUsers); // ← before /:id
router.delete('/users/bulk', sensitiveOpsLimiter, usersCtrl.bulkDeactivateUsers); // ← before /:id
router.get('/users/field-values', usersCtrl.getUserFieldValues); // ← before /:id
router.get('/users/archived', usersCtrl.getArchivedUsers); // ← before /:id
router.post('/users/staff', sensitiveOpsLimiter, usersCtrl.addStaffUser);
router.get('/users', usersCtrl.getUsers);
router.get('/users/:id', usersCtrl.getUser);
router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser);
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);
// ── Mount ─────────────────────────────────────────────────────────────────────
router.use('/dashboard', dashboardRoutes);
router.use('/users', usersRoutes);
router.use('/groups', groupsRoutes);
router.use('/assets', assetsRoutes);
module.exports = router;
+22
View File
@@ -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;
+9
View File
@@ -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;
+23
View File
@@ -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;
+22
View File
@@ -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;