From 4e6017c79b4e84be23f43d4d87760b85563ac8dd Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Sat, 9 May 2026 22:17:24 +0800 Subject: [PATCH] Adjusted --- controllers/admin/assets.controller.js | 350 +++++++++ controllers/admin/dashboard.controller.js | 143 ++++ controllers/admin/documentation/assets.md | 380 ++++++++++ .../admin/documentation/user_groups.md | 491 ++++++++++++ controllers/admin/documentation/users.md | 463 ++++++++++++ controllers/admin/user_groups.controller.js | 397 ++++++++++ controllers/admin/users.controller.js | 710 +++--------------- models/assets/assets.mdl.js | 84 +++ models/users/user_groups.attributes.js | 1 - models/users/user_groups.mdl.js | 2 +- package-lock.json | 136 ++++ package.json | 4 +- routes/admin/admin.routes.js | 74 +- routes/admin/assets.routes.js | 22 + routes/admin/dashboard.routes.js | 9 + routes/admin/groups.routes.js | 23 + routes/admin/users.routes.js | 22 + 17 files changed, 2659 insertions(+), 652 deletions(-) create mode 100644 controllers/admin/assets.controller.js create mode 100644 controllers/admin/dashboard.controller.js create mode 100644 controllers/admin/documentation/assets.md create mode 100644 controllers/admin/documentation/user_groups.md create mode 100644 controllers/admin/documentation/users.md create mode 100644 controllers/admin/user_groups.controller.js create mode 100644 models/assets/assets.mdl.js create mode 100644 routes/admin/assets.routes.js create mode 100644 routes/admin/dashboard.routes.js create mode 100644 routes/admin/groups.routes.js create mode 100644 routes/admin/users.routes.js diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js new file mode 100644 index 0000000..2bcaa4f --- /dev/null +++ b/controllers/admin/assets.controller.js @@ -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." }); + } +}; \ No newline at end of file diff --git a/controllers/admin/dashboard.controller.js b/controllers/admin/dashboard.controller.js new file mode 100644 index 0000000..a245f45 --- /dev/null +++ b/controllers/admin/dashboard.controller.js @@ -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.' }); + } +}; \ No newline at end of file diff --git a/controllers/admin/documentation/assets.md b/controllers/admin/documentation/assets.md new file mode 100644 index 0000000..114516d --- /dev/null +++ b/controllers/admin/documentation/assets.md @@ -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`. \ No newline at end of file diff --git a/controllers/admin/documentation/user_groups.md b/controllers/admin/documentation/user_groups.md new file mode 100644 index 0000000..a95661b --- /dev/null +++ b/controllers/admin/documentation/user_groups.md @@ -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. \ No newline at end of file diff --git a/controllers/admin/documentation/users.md b/controllers/admin/documentation/users.md new file mode 100644 index 0000000..58a2008 --- /dev/null +++ b/controllers/admin/documentation/users.md @@ -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. \ No newline at end of file diff --git a/controllers/admin/user_groups.controller.js b/controllers/admin/user_groups.controller.js new file mode 100644 index 0000000..883e981 --- /dev/null +++ b/controllers/admin/user_groups.controller.js @@ -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); + } +}; \ No newline at end of file diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index 98b3661..50db432 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -2,50 +2,38 @@ * 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'); +const mdl_Users = require('../../models/users/users.mdl'); const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const sendEmail = require('../../services/email.service'); -const R = require('../../utils/response.util'); -const { paginate, auditInclude } = require('../../utils/paginate.util'); +const R = require('../../utils/response.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 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, { excludeAttributes: usersExclude, - jsonbSchemas: usersSchemas, - jsonbColumn: 'personal_info', - auditOptions: { mdl_Users, parentAlias: 'User' }, + jsonbSchemas: usersSchemas, + jsonbColumn: 'personal_info', + auditOptions: { mdl_Users, parentAlias: 'User' }, }); return R.success(res, 'Users retrieved.', result); @@ -55,29 +43,35 @@ exports.getUsers = async (req, res) => { } }; -// ─── GET single user ─────────────────────────────────────────────────────────── +// ─── GET ONE ────────────────────────────────────────────────────────────────── + exports.getUser = async (req, res) => { 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,44 +80,36 @@ 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 plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12); + const hashed = await bcrypt.hash(plainPassword, 12); const passwordExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - - const enriched = enrichPersonalInfo(personal_info); - const fullName = enriched?.name?.full_name ?? email; + const enriched = enrichPersonalInfo(personal_info); + const fullName = enriched?.name?.full_name ?? email; const user = await mdl_Users.create({ email, - password: hashed, - password_expires_at: passwordExpiresAt, // ← requires column in DB - must_change_password: true, // ← force change on first login - acc_type: 'staff', - reg_type: 'system', - is_active: true, - is_verified: true, - createdBy: req.user.user_id, - personal_info: enriched, + password: hashed, + password_expires_at: passwordExpiresAt, + must_change_password: true, + acc_type: 'staff', + reg_type: 'system', + is_active: true, + is_verified: true, + createdBy: req.user.user_id, + personal_info: enriched, }); await sendEmail({ - to: email, type: "ADD_STAFF", data: { - name: fullName, - email, - password: plainPassword, - expiryHours: 24, - } + to: email, type: 'ADD_STAFF', data: { + name: fullName, email, password: plainPassword, expiryHours: 24, + }, }); return R.success(res, 'Staff user created successfully.', { - user_id: user.user_id, - email: user.email, + user_id: user.user_id, + email: user.email, acc_type: user.acc_type, }, 201); } catch (err) { @@ -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)), + 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) 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 } @@ -308,278 +258,26 @@ exports.bulkRestoreUsers = async (req, res) => { return R.success(res, `${deletedIds.length} user(s) restored successfully.`, { restored_ids: deletedIds, - skipped_ids: ids.filter((id) => !deletedIds.includes(id)), + 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({ - where: { user_id: req.params.id }, + where: { user_id: req.params.id }, attributes: { exclude: ['refresh_token_hash'] }, - order: [['createdAt', 'DESC']], + order: [['createdAt', 'DESC']], }); 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,105 +286,98 @@ 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 - FROM users u1 - JOIN users u2 ON u2.user_id = u1."${field}" - WHERE u1."${field}" IS NOT NULL - AND u2."personal_info"->'name'->>'full_name' IS NOT NULL - ORDER BY value ASC + SELECT DISTINCT u2."personal_info"->'name'->>'full_name' AS value + FROM users u1 + JOIN users u2 ON u2.user_id = u1."${field}" + WHERE u1."${field}" IS NOT NULL + 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, + 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, + 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 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({ attributes: [[Sequelize.literal(`DISTINCT ${jsonbPath}`), 'value']], where: Sequelize.literal(`${jsonbPath} IS NOT NULL`), - raw: true, + raw: true, }); - const values = results.map(r => r.value).filter(Boolean).sort(); - return R.success(res, 'Field values retrieved.', 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, { excludeAttributes: usersExclude, - jsonbSchemas: usersSchemas, - jsonbColumn: 'personal_info', - auditOptions: { mdl_Users, parentAlias: 'User' }, + jsonbSchemas: usersSchemas, + 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 }, }, }); @@ -695,175 +386,4 @@ exports.getArchivedUsers = async (req, res) => { console.error('[ADMIN][GET ARCHIVED USERS]', err); return R.error(res, 'Could not retrieve archived users.', 500); } -}; - -// ─── BULK DEACTIVATE groups ─────────────────────────────────────────────────── -exports.bulkDeactivateGroups = async (req, res) => { - try { - const { ids } = req.body; - - if (!Array.isArray(ids) || ids.length === 0) - return R.error(res, 'No group IDs provided.', 400); - - const groups = await mdl_UserGroups.findAll({ - where: { group_id: ids }, - }); - - if (!groups.length) - return R.error(res, 'No groups found.', 404); - - const activeGroups = groups.filter((g) => g.is_active && !g.deletedAt); - - if (!activeGroups.length) - return R.error(res, 'All selected groups are already deactivated.', 400); - - const activeIds = activeGroups.map((g) => g.group_id); - - await mdl_UserGroups.update( - { is_active: false, deletedBy: req.user.user_id }, - { where: { group_id: activeIds } } - ); - - await mdl_UserGroups.destroy({ - where: { group_id: activeIds }, - }); - - return R.success(res, `${activeIds.length} group(s) deactivated successfully.`, { - deactivated_ids: activeIds, - skipped_ids: ids.filter((id) => !activeIds.includes(id)), - }); - } catch (err) { - console.error('[ADMIN][BULK DEACTIVATE GROUPS]', err); - return R.error(res, 'Could not deactivate groups.', 500); - } -}; - -// ─── BULK RESTORE groups ────────────────────────────────────────────────────── -exports.bulkRestoreGroups = async (req, res) => { - try { - const { ids } = req.body; - - if (!Array.isArray(ids) || ids.length === 0) - return R.error(res, 'No group IDs provided.', 400); - - const groups = await mdl_UserGroups.findAll({ - where: { group_id: ids }, - paranoid: false, - }); - - if (!groups.length) - return R.error(res, 'No groups found.', 404); - - const deletedGroups = groups.filter((g) => g.deletedAt); - - if (!deletedGroups.length) - return R.error(res, 'All selected groups are already active.', 400); - - const deletedIds = deletedGroups.map((g) => g.group_id); - - await mdl_UserGroups.restore({ where: { group_id: deletedIds } }); - - await mdl_UserGroups.update( - { is_active: true, updatedBy: req.user.user_id, deletedBy: null }, - { where: { group_id: deletedIds }, paranoid: false } - ); - - return R.success(res, `${deletedIds.length} group(s) restored successfully.`, { - restored_ids: deletedIds, - skipped_ids: ids.filter((id) => !deletedIds.includes(id)), - }); - } catch (err) { - console.error('[ADMIN][BULK RESTORE GROUPS]', err); - return R.error(res, 'Could not restore groups.', 500); - } -}; - -exports.getArchivedGroups = async (req, res) => { - try { - - const result = await paginate(mdl_UserGroups, req, { - excludeAttributes: groupExclude, - jsonbSchemas: groupSchemas, - computedAttributes: groupComputed, // ← just pass it, paginate handles the rest - auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, - findOptions: { - paranoid: false, - where: { - deletedAt: { [Op.ne]: null }, - is_active: false, - }, - }, - }); - - return R.success(res, 'Archived groups retrieved.', result); - } catch (err) { - console.error('[ADMIN][GET ARCHIVED GROUPS]', err); - return R.error(res, 'Could not retrieve archived groups.', 500); - } -}; - -// ─── GET group field values ─────────────────────────────────────────────────── -exports.getGroupFieldValues = async (req, res) => { - try { - const { field } = req.query; - if (!field) return R.error(res, 'Field is required.', 400); - - const allowedFields = Object.keys(mdl_UserGroups.rawAttributes).filter( - (f) => !['deletedAt'].includes(f) - ); - - const dateFields = ['createdAt', 'updatedAt', 'deletedAt']; - - if (!field.includes('.')) { - if (!allowedFields.includes(field)) - return R.error(res, 'Invalid or restricted field.', 400); - - // ─── Audit By fields — return names instead of IDs ─────────────────── - if (auditByFields.includes(field)) { - const [rows] = await sequelize.query(` - SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value - FROM user_groups g - JOIN users u ON u.user_id = g."${field}" - WHERE g."${field}" IS NOT NULL - AND u."personal_info"->'name'->>'full_name' IS NOT NULL - ORDER BY value ASC - `); - - const values = rows.map((r) => r.value).filter(Boolean); - return R.success(res, 'Field values retrieved.', values); - } - - // ─── Date fields ────────────────────────────────────────────────────── - if (dateFields.includes(field)) { - const results = await mdl_UserGroups.findAll({ - attributes: [ - [Sequelize.fn('DISTINCT', Sequelize.fn('DATE', Sequelize.col(field))), 'value'], - ], - where: { [field]: { [Op.ne]: null } }, - order: [[Sequelize.fn('DATE', Sequelize.col(field)), 'DESC']], - paranoid: false, - raw: true, - }); - - const values = results.map((r) => r.value).filter(Boolean); - return R.success(res, 'Field values retrieved.', values); - } - - // ─── Regular fields ─────────────────────────────────────────────────── - const results = await mdl_UserGroups.findAll({ - attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']], - where: { [field]: { [Op.ne]: null } }, - paranoid: false, - raw: true, - }); - - const values = results.map((r) => r.value).filter(Boolean).sort(); - return R.success(res, 'Field values retrieved.', values); - } - - return R.error(res, 'JSONB fields are not supported for groups.', 400); - } catch (err) { - console.error('[ADMIN][GET GROUP FIELD VALUES]', err); - return R.error(res, 'Could not retrieve field values.', 500); - } }; \ No newline at end of file diff --git a/models/assets/assets.mdl.js b/models/assets/assets.mdl.js new file mode 100644 index 0000000..9f1c9f0 --- /dev/null +++ b/models/assets/assets.mdl.js @@ -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; \ No newline at end of file diff --git a/models/users/user_groups.attributes.js b/models/users/user_groups.attributes.js index 327e627..9d1aa51 100644 --- a/models/users/user_groups.attributes.js +++ b/models/users/user_groups.attributes.js @@ -1,5 +1,4 @@ const excludeAttributes = [ - "description" ]; const jsonbSchemas = { diff --git a/models/users/user_groups.mdl.js b/models/users/user_groups.mdl.js index 7abd849..dfcd438 100644 --- a/models/users/user_groups.mdl.js +++ b/models/users/user_groups.mdl.js @@ -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 ──────────────────────────────────────────────────────────── diff --git a/package-lock.json b/package-lock.json index 46d4a1f..bede074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e889ebb..6f1dd9c 100644 --- a/package.json +++ b/package.json @@ -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", @@ -32,4 +34,4 @@ "devDependencies": { "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index bfe1c1f..b116219 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -4,69 +4,35 @@ * 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 ***********************************************************************************************************************************************************************/ const express = require('express'); -const router = express.Router(); +const router = express.Router(); -const usersCtrl = require('../../controllers/admin/users.controller'); -const { authenticate } = require('../../middleware/auth.middleware'); -const { requireAdmin } = require('../../middleware/rbac.middleware'); -const { adminLimiter, sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware'); +const { authenticate } = require('../../middleware/auth.middleware'); +const { requireAdmin } = require('../../middleware/rbac.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; \ No newline at end of file diff --git a/routes/admin/assets.routes.js b/routes/admin/assets.routes.js new file mode 100644 index 0000000..e2f3900 --- /dev/null +++ b/routes/admin/assets.routes.js @@ -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; \ No newline at end of file diff --git a/routes/admin/dashboard.routes.js b/routes/admin/dashboard.routes.js new file mode 100644 index 0000000..8fd2739 --- /dev/null +++ b/routes/admin/dashboard.routes.js @@ -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; \ No newline at end of file diff --git a/routes/admin/groups.routes.js b/routes/admin/groups.routes.js new file mode 100644 index 0000000..d31c5ef --- /dev/null +++ b/routes/admin/groups.routes.js @@ -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; \ No newline at end of file diff --git a/routes/admin/users.routes.js b/routes/admin/users.routes.js new file mode 100644 index 0000000..5c9ecbf --- /dev/null +++ b/routes/admin/users.routes.js @@ -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; \ No newline at end of file