mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_AchievementDefinitions = require('../../models/users/achievement_definitions.mdl');
|
||||
const CourseAchievement = require('../../models/courses/course_achievement.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
const VALID_TYPES = ['badge', 'milestone'];
|
||||
|
||||
// ─── GET /admin/achievements ──────────────────────────────────────────────────
|
||||
|
||||
exports.getAchievements = async (req, res) => {
|
||||
try {
|
||||
const achievements = await mdl_AchievementDefinitions.findAll({
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Achievements retrieved.', achievements);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ACHIEVEMENTS]', err);
|
||||
return R.error(res, 'Could not retrieve achievements.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/achievements/:id ──────────────────────────────────────────────
|
||||
|
||||
exports.getAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||
return R.success(res, 'Achievement retrieved.', achievement);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not retrieve achievement.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/achievements ─────────────────────────────────────────────────
|
||||
|
||||
exports.createAchievement = async (req, res) => {
|
||||
try {
|
||||
const { key, type, label, description, icon, trigger, is_active } = req.body;
|
||||
if (!key || !label) return R.error(res, 'key and label are required.', 400);
|
||||
if (type && !VALID_TYPES.includes(type)) return R.error(res, `type must be one of: ${VALID_TYPES.join(', ')}.`, 400);
|
||||
|
||||
const exists = await mdl_AchievementDefinitions.findOne({ where: { key } });
|
||||
if (exists) return R.error(res, `An achievement with key "${key}" already exists.`, 409);
|
||||
|
||||
const achievement = await mdl_AchievementDefinitions.create({
|
||||
key,
|
||||
type: type || 'badge',
|
||||
label,
|
||||
description: description ?? null,
|
||||
icon: icon || null,
|
||||
trigger: trigger || null,
|
||||
is_active: is_active !== undefined ? !!is_active : true,
|
||||
is_system: false, // only seed data may be system-protected
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'create_achievement', { entityType: 'achievement', details: { key, label } });
|
||||
|
||||
return R.success(res, 'Achievement created.', achievement, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not create achievement.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT /admin/achievements/:id ──────────────────────────────────────────────
|
||||
|
||||
exports.updateAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||
|
||||
const { key, type, label, description, icon, trigger, is_active } = req.body;
|
||||
|
||||
if (achievement.is_system && key !== undefined && key !== achievement.key)
|
||||
return R.error(res, 'The key of a system achievement cannot be changed.', 400);
|
||||
if (achievement.is_system && type !== undefined && type !== achievement.type)
|
||||
return R.error(res, 'The type of a system achievement cannot be changed.', 400);
|
||||
if (type && !VALID_TYPES.includes(type)) return R.error(res, `type must be one of: ${VALID_TYPES.join(', ')}.`, 400);
|
||||
|
||||
if (!achievement.is_system && key !== undefined && key !== achievement.key) {
|
||||
const exists = await mdl_AchievementDefinitions.findOne({ where: { key } });
|
||||
if (exists) return R.error(res, `An achievement with key "${key}" already exists.`, 409);
|
||||
}
|
||||
|
||||
await achievement.update({
|
||||
key: (!achievement.is_system && key !== undefined) ? key : achievement.key,
|
||||
type: (!achievement.is_system && type !== undefined) ? type : achievement.type,
|
||||
label: label ?? achievement.label,
|
||||
description: description !== undefined ? (description || null) : achievement.description,
|
||||
icon: icon !== undefined ? (icon || null) : achievement.icon,
|
||||
trigger: trigger !== undefined ? (trigger || null) : achievement.trigger,
|
||||
is_active: is_active !== undefined ? !!is_active : achievement.is_active,
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'update_achievement', { entityType: 'achievement', details: { id: achievement.achievement_definition_id, key: achievement.key } });
|
||||
|
||||
return R.success(res, 'Achievement updated.', achievement);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not update achievement.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE /admin/achievements/:id ───────────────────────────────────────────
|
||||
|
||||
exports.deleteAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||
if (achievement.is_system) return R.error(res, 'Built-in system achievements cannot be deleted.', 400);
|
||||
|
||||
const assignedCourses = await CourseAchievement.count({ where: { achievement_key: achievement.key } });
|
||||
if (assignedCourses > 0)
|
||||
return R.error(res, `Cannot delete — ${assignedCourses} course(s) still reference this achievement. Unassign it first.`, 409);
|
||||
|
||||
await achievement.destroy();
|
||||
logActivity(req.user?.user_id, 'delete_achievement', { entityType: 'achievement', details: { key: achievement.key } });
|
||||
return R.success(res, 'Achievement deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DELETE ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not delete achievement.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,573 @@
|
||||
// controllers/admin/advertisements.controller.js
|
||||
|
||||
const sequelize = require("../../config/db.config");
|
||||
const Advertisement = require("../../models/advertisements/advertisements.mdl");
|
||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes");
|
||||
const { PLACEMENT_MAP, PLACEMENT_KEYS } = require("../../models/advertisements/advertisements.placements");
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"];
|
||||
|
||||
// Fields needed off the associated Asset to render a preview AND (for S3 assets)
|
||||
// mint a stream token — storage_key is stripped again in attachImageStreamToken
|
||||
// before the row is ever sent out.
|
||||
const AD_IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"];
|
||||
|
||||
// ─── Media proxying ─────────────────────────────────────────────────────────
|
||||
// Mirrors controllers/admin/assets.controller.js's redactS3Url/attachStreamTokens.
|
||||
// Private (S3-backed) advertisement images must never expose a raw file_url to
|
||||
// the browser — mint a short-lived stream token instead so the frontend resolves
|
||||
// it through GET /api/client/media/stream/:token. Public/chibisafe images keep
|
||||
// their direct file_url (no proxy needed).
|
||||
async function attachImageStreamToken(image, req) {
|
||||
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||
return image;
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||
image.stream_token = token;
|
||||
image.file_url = null;
|
||||
image.thumbnail_url = null;
|
||||
delete image.storage_key;
|
||||
return image;
|
||||
}
|
||||
|
||||
// ─── Status derivation ─────────────────────────────────────────────────────
|
||||
// status is never trusted as manually-set truth — it's derived from is_active
|
||||
// + start_date/end_date every time an advertisement is read or written.
|
||||
// "archived" is the only status that bypasses derivation (set by archive/restore).
|
||||
function deriveStatus(advertisement) {
|
||||
if (advertisement.deletedAt) return "archived";
|
||||
if (!advertisement.is_active) return "draft";
|
||||
|
||||
const now = new Date();
|
||||
const start = advertisement.start_date ? new Date(advertisement.start_date) : null;
|
||||
const end = advertisement.end_date ? new Date(advertisement.end_date) : null;
|
||||
|
||||
if (end && end < now) return "expired";
|
||||
if (start && start > now) return "scheduled";
|
||||
return "active";
|
||||
}
|
||||
|
||||
function normalizeCtas(ctas) {
|
||||
if (!Array.isArray(ctas)) return [];
|
||||
return ctas
|
||||
.filter((c) => c && typeof c.label === "string" && typeof c.link === "string")
|
||||
.slice(0, 2) // hard cap: max 2 CTAs per advertisement
|
||||
.map((c, i) => ({
|
||||
label: c.label.trim(),
|
||||
link: c.link.trim(),
|
||||
// Variant is always derived from position — first CTA is "default"
|
||||
// (primary), second is "outline" — not user-selectable, so any
|
||||
// client-sent variant is ignored.
|
||||
variant: i === 0 ? "default" : "outline",
|
||||
}));
|
||||
}
|
||||
|
||||
// Hard cap: max 2 badge labels per advertisement (matches MAX_BADGE_LABELS on the frontend)
|
||||
function normalizeBadgeLabels(labels) {
|
||||
if (!Array.isArray(labels)) return [];
|
||||
return labels
|
||||
.filter((l) => typeof l === "string" && l.trim().length > 0)
|
||||
.map((l) => l.trim())
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
async function applyAdvertisementFields(advertisement, body) {
|
||||
// placement is the only settable "where" — type/format is always derived
|
||||
// from the placement's registry entry, never accepted directly from the body.
|
||||
if (body.placement !== undefined) {
|
||||
const entry = PLACEMENT_MAP[body.placement];
|
||||
if (!entry) {
|
||||
const err = new Error(`Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
advertisement.placement = body.placement;
|
||||
advertisement.type = entry.format;
|
||||
}
|
||||
|
||||
// status is intentionally NOT settable here — it's derived via deriveStatus()
|
||||
// right before save, based on is_active + start_date/end_date.
|
||||
|
||||
// content_mode is no longer an admin choice (the Image Only / Text with
|
||||
// Image toggle was removed — every ad now carries the same mandatory
|
||||
// badge/headline/description/image/link shape) — like type/format, it's
|
||||
// fixed server-side rather than trusted from the request body. Legacy
|
||||
// "image" mode rows keep that value until next edited.
|
||||
advertisement.content_mode = "content";
|
||||
|
||||
if (body.badge_labels !== undefined) advertisement.badge_labels = normalizeBadgeLabels(body.badge_labels);
|
||||
if (body.headline !== undefined) advertisement.headline = body.headline;
|
||||
if (body.description !== undefined) advertisement.description = body.description;
|
||||
if (body.image_url !== undefined) advertisement.image_url = body.image_url;
|
||||
if (body.redirect_link !== undefined) advertisement.redirect_link = body.redirect_link || null;
|
||||
if (body.landing_page !== undefined) advertisement.landing_page = body.landing_page || null;
|
||||
|
||||
if (body.image_asset_id !== undefined) {
|
||||
if (body.image_asset_id === null) {
|
||||
advertisement.image_asset_id = null;
|
||||
} else {
|
||||
const asset = await mdl_Assets.findOne({ where: { asset_id: body.image_asset_id, deletedAt: null } });
|
||||
if (!asset) {
|
||||
const err = new Error("Selected image file was not found.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
advertisement.image_asset_id = asset.asset_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.ctas !== undefined) advertisement.ctas = normalizeCtas(body.ctas);
|
||||
|
||||
if (body.start_date !== undefined) advertisement.start_date = body.start_date || null;
|
||||
if (body.end_date !== undefined) advertisement.end_date = body.end_date || null;
|
||||
|
||||
if (body.order !== undefined) advertisement.order = parseInt(body.order) || 0;
|
||||
if (body.is_active !== undefined) advertisement.is_active = body.is_active === true || body.is_active === "true";
|
||||
|
||||
if (body.size !== undefined) {
|
||||
if (body.size !== null && !["sm", "md", "lg", "xl"].includes(body.size)) {
|
||||
const err = new Error(`Invalid size. Must be one of: sm, md, lg, xl`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
advertisement.size = body.size || null;
|
||||
}
|
||||
|
||||
// Every ad now carries the same mandatory shape — badge label(s), headline,
|
||||
// description, image, and a single link — enforced here as defense-in-depth
|
||||
// alongside the frontend's Zod schema. The Admin Add/Edit Advertisement
|
||||
// forms always submit the full shape, so this only ever fires on malformed
|
||||
// requests — it does not retroactively touch existing incomplete rows,
|
||||
// it just blocks saving one until it's brought up to the new shape.
|
||||
if (!advertisement.badge_labels?.length) {
|
||||
const err = new Error("At least one badge label is required.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!advertisement.headline?.trim()) {
|
||||
const err = new Error("Headline is required.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!advertisement.description?.trim()) {
|
||||
const err = new Error("Description is required.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!advertisement.image_asset_id) {
|
||||
const err = new Error("Image is required.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!advertisement.redirect_link?.trim()) {
|
||||
const err = new Error("Link is required.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Recompute status now that is_active/start_date/end_date are all up to date
|
||||
advertisement.status = deriveStatus(advertisement);
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Keeps the stored `status` column in sync with deriveStatus() before the
|
||||
// filtered query runs — status is otherwise only recomputed on individual
|
||||
// row reads, so filtering by status (e.g. "expired") would miss rows whose
|
||||
// start_date/end_date lapsed since they were last saved.
|
||||
async function syncDerivedStatuses() {
|
||||
await sequelize.query(`
|
||||
UPDATE advertisements
|
||||
SET status = CASE
|
||||
WHEN is_active = false THEN 'draft'
|
||||
WHEN end_date IS NOT NULL AND end_date < NOW() THEN 'expired'
|
||||
WHEN start_date IS NOT NULL AND start_date > NOW() THEN 'scheduled'
|
||||
ELSE 'active'
|
||||
END
|
||||
WHERE "deletedAt" IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
exports.getAdvertisements = async (req, res) => {
|
||||
try {
|
||||
await syncDerivedStatuses();
|
||||
|
||||
const result = await paginate(Advertisement, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Advertisement' },
|
||||
findOptions: {
|
||||
where: { ...notDeleted },
|
||||
include: [{
|
||||
model: mdl_Assets,
|
||||
as: "image",
|
||||
attributes: AD_IMAGE_ATTRIBUTES,
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
// Resync status on the way out — never trust what's stored, since
|
||||
// start_date/end_date may have lapsed since the row was last saved.
|
||||
if (Array.isArray(result?.data)) {
|
||||
result.data = await Promise.all(result.data.map(async (row) => {
|
||||
if (row.image) await attachImageStreamToken(row.image, req);
|
||||
return { ...row, status: deriveStatus(row) };
|
||||
}));
|
||||
}
|
||||
|
||||
return R.success(res, "Advertisements retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve advertisements.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { advertisementId } = req.params;
|
||||
if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400);
|
||||
|
||||
const advertisement = await Advertisement.findOne({
|
||||
where: { advertisement_id: advertisementId, ...notDeleted },
|
||||
include: [
|
||||
{ model: mdl_Assets, as: "image", attributes: AD_IMAGE_ATTRIBUTES, required: false },
|
||||
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
||||
],
|
||||
});
|
||||
|
||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
||||
|
||||
const json = advertisement.toJSON();
|
||||
json.status = deriveStatus(json);
|
||||
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
|
||||
if (json.creator) {
|
||||
json.creator = {
|
||||
user_id: json.creator.user_id,
|
||||
full_name: json.creator.personal_info?.name?.full_name ?? null,
|
||||
};
|
||||
}
|
||||
if (json.updater) {
|
||||
json.updater = {
|
||||
user_id: json.updater.user_id,
|
||||
full_name: json.updater.personal_info?.name?.full_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return R.success(res, "Advertisement retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][GET ONE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.createAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { placement, createdBy } = req.body;
|
||||
|
||||
if (!placement) return R.error(res, "placement is required.", 400);
|
||||
const entry = PLACEMENT_MAP[placement];
|
||||
if (!entry) return R.error(res, `Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`, 400);
|
||||
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const advertisement = await Advertisement.build({ placement, type: entry.format, createdBy });
|
||||
await applyAdvertisementFields(advertisement, req.body);
|
||||
|
||||
// No manual "order" input in the UI anymore — new ads always append to
|
||||
// the end of their placement's priority list rather than colliding at 0.
|
||||
if (req.body.order === undefined) {
|
||||
advertisement.order = await Advertisement.count({ where: { placement, ...notDeleted }, transaction: t });
|
||||
}
|
||||
|
||||
await advertisement.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { placement: advertisement.placement, type: advertisement.type } });
|
||||
return R.success(res, "Advertisement created.", { data: advertisement }, 201);
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* connection gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][CREATE]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { advertisementId } = req.params;
|
||||
if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400);
|
||||
|
||||
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } });
|
||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await applyAdvertisementFields(advertisement, req.body);
|
||||
advertisement.updatedBy = req.body.updatedBy ?? null;
|
||||
await advertisement.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'update_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
|
||||
return R.success(res, "Advertisement updated.", { data: advertisement });
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][UPDATE]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── REORDER ──────────────────────────────────────────────────────────────────
|
||||
// `order` is scoped per placement (mirrors controllers/client/advertisements.controller.js's
|
||||
// getActiveAdvertisement[List], which picks the lowest `order` within a placement to show
|
||||
// first) — Move Up/Down swaps position among siblings sharing the same placement, then
|
||||
// re-sequences the whole group to 0..n-1. Re-sequencing (not just swapping the two `order`
|
||||
// values) is what makes this self-healing against legacy ties, since every ad defaulted to
|
||||
// order: 0 before this feature existed — a plain swap between two tied rows would no-op.
|
||||
exports.reorderAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { advertisementId } = req.params;
|
||||
const { direction } = req.body;
|
||||
if (!["up", "down"].includes(direction)) return R.error(res, "direction must be 'up' or 'down'.", 400);
|
||||
|
||||
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } });
|
||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
||||
|
||||
const group = await Advertisement.findAll({
|
||||
where: { placement: advertisement.placement, ...notDeleted },
|
||||
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
const index = group.findIndex((a) => a.advertisement_id === advertisement.advertisement_id);
|
||||
const swapWith = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapWith < 0 || swapWith >= group.length) {
|
||||
return R.error(res, `This ad is already at the ${direction === "up" ? "top" : "bottom"} of its placement.`, 400);
|
||||
}
|
||||
|
||||
[group[index], group[swapWith]] = [group[swapWith], group[index]];
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await Promise.all(group.map((a, i) => a.update({ order: i }, { transaction: t })));
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* connection gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'reorder_advertisement', {
|
||||
entityType: 'advertisement', entityId: Number(advertisementId),
|
||||
details: { placement: advertisement.placement, direction },
|
||||
});
|
||||
return R.success(res, "Order updated.", {
|
||||
data: { updates: group.map((a) => ({ advertisement_id: a.advertisement_id, order: a.order })) },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][REORDER]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { advertisementId } = req.params;
|
||||
if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400);
|
||||
|
||||
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } });
|
||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
||||
|
||||
// Freeze the derived status (e.g. "expired") onto the row before it goes
|
||||
// paranoid — the archived list trusts this stored value as-is and never
|
||||
// re-derives it, so "Remove Expired" would otherwise miss ads that had
|
||||
// already lapsed at the moment an admin manually archived them.
|
||||
await advertisement.update({ deletedBy: req.body.deletedBy ?? null, status: deriveStatus(advertisement) });
|
||||
await advertisement.destroy();
|
||||
logActivity(req.user?.user_id, 'archive_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
|
||||
return R.success(res, "Advertisement archived.");
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveAdvertisements = async (req, res) => {
|
||||
try {
|
||||
const { ids, deletedBy } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids }, ...notDeleted } });
|
||||
if (!advertisements.length) return R.error(res, "No advertisements found.", 404);
|
||||
|
||||
const activeIds = advertisements.map((a) => a.advertisement_id);
|
||||
|
||||
// Same status-freeze as the single-archive path — resync each row's
|
||||
// status right before it goes paranoid so "Remove Expired" can trust it.
|
||||
await Promise.all(advertisements.map((a) =>
|
||||
a.update({ deletedBy: deletedBy ?? null, status: deriveStatus(a) })
|
||||
));
|
||||
await Advertisement.destroy({ where: { advertisement_id: { [Op.in]: activeIds } } });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_archive_advertisements', { entityType: 'advertisement', details: { ids: activeIds, count: activeIds.length } });
|
||||
return R.success(res, `${activeIds.length} advertisement(s) archived.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][BULK ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { advertisementId } = req.params;
|
||||
|
||||
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId }, paranoid: false });
|
||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
||||
if (!advertisement.deletedAt) return R.error(res, "Advertisement is not archived.", 400);
|
||||
|
||||
await advertisement.restore();
|
||||
await advertisement.update({ deletedBy: null });
|
||||
logActivity(req.user?.user_id, 'restore_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
|
||||
return R.success(res, "Advertisement restored.", { data: advertisement });
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreAdvertisements = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!advertisements.length) return R.error(res, "No advertisements found.", 404);
|
||||
|
||||
const archived = advertisements.filter((a) => a.deletedAt);
|
||||
if (!archived.length) return R.error(res, "All selected advertisements are already active.", 400);
|
||||
|
||||
const archivedIds = archived.map((a) => a.advertisement_id);
|
||||
|
||||
await Advertisement.restore({ where: { advertisement_id: { [Op.in]: archivedIds } } });
|
||||
await Advertisement.update({ deletedBy: null }, { where: { advertisement_id: { [Op.in]: archivedIds } }, paranoid: false });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_restore_advertisements', { entityType: 'advertisement', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} advertisement(s) restored.`, {
|
||||
restored_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][BULK RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedAdvertisements = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Advertisement, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "archived",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Advertisement' },
|
||||
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
||||
});
|
||||
return R.success(res, "Archived advertisements retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][GET ARCHIVED]", err);
|
||||
return R.error(res, "Could not retrieve archived advertisements.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getAdvertisementFieldValues = getFieldValues(Advertisement, "ADVERTISEMENT");
|
||||
|
||||
// ─── PERMANENT DELETE (single) ────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { advertisementId } = req.params;
|
||||
|
||||
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId }, paranoid: false });
|
||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
||||
if (!advertisement.deletedAt) return R.error(res, "Advertisement must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
await advertisement.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
|
||||
return R.success(res, "Advertisement permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete advertisement.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAdvertisements = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!advertisements.length) return R.error(res, "No advertisements found.", 404);
|
||||
|
||||
const archived = advertisements.filter((a) => a.deletedAt);
|
||||
if (!archived.length) return R.error(res, "All selected advertisements must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archived.map((a) => a.advertisement_id);
|
||||
|
||||
await Advertisement.destroy({ where: { advertisement_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_advertisements', { entityType: 'advertisement', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} advertisement(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete advertisements.", 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,850 @@
|
||||
// controllers/admin/assets.controller.js
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const Asset = require("../../models/assets/assets.mdl");
|
||||
const chibi = require("../../services/chibisafe.service");
|
||||
const s3 = require("../../services/s3.service");
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
const { extractVideoMeta } = require("../../services/ffprobe.service");
|
||||
const ffmpegSvc = require("../../services/ffmpeg.service");
|
||||
const assetTranscode = require("../../services/assetTranscode.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes");
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// List queries keep storage_key selected (unlike adminExclude) so
|
||||
// attachStreamTokens can sign a stream token server-side without a second
|
||||
// query — it's deleted from every row before the response is sent.
|
||||
const LIST_QUERY_EXCLUDE = adminExclude.filter((f) => f !== "storage_key");
|
||||
|
||||
// ─── In-memory list cache (no Redis yet) ───────────────────────────────────────
|
||||
// Short TTL just to absorb bursts of identical GET /admin/assets calls — e.g.
|
||||
// AssetPickerSheet being opened/closed repeatedly with the same filters — so
|
||||
// Postgres isn't re-queried on every toggle. Cleared on any mutation below.
|
||||
// Single-process only; fine for one instance, won't stay consistent across
|
||||
// multiple app instances without a shared store like Redis.
|
||||
const LIST_CACHE_TTL_MS = 20_000;
|
||||
const listCache = new Map(); // queryKey -> { result, expiresAt }
|
||||
|
||||
function listCacheKey(req) {
|
||||
return JSON.stringify({
|
||||
page: req.query.page, limit: req.query.limit,
|
||||
filters: req.query.filters, sort: req.query.sort,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateListCache() { listCache.clear(); }
|
||||
|
||||
function resolveFileType(mimeType = "") {
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "audio";
|
||||
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
|
||||
}
|
||||
|
||||
function resolveExtension(originalName = "") {
|
||||
return path.extname(originalName).replace(".", "").toLowerCase() || null;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
// ─── Provider resolver ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Returns the correct service module based on storage_provider.
|
||||
// Both chibi and s3 expose the same interface: uploadFile / deleteFile.
|
||||
//
|
||||
function getProvider(storageProvider) {
|
||||
if (storageProvider === "s3") return s3;
|
||||
if (storageProvider === "chibisafe") return chibi;
|
||||
return null; // local / other — no remote provider needed
|
||||
}
|
||||
|
||||
// ─── rollbackUploads ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Best-effort cleanup after a failed DB transaction.
|
||||
// uploads: [{ key, provider }]
|
||||
//
|
||||
async function rollbackUploads(uploads = []) {
|
||||
for (const { key, provider } of uploads) {
|
||||
if (!key || !provider) continue;
|
||||
const svc = getProvider(provider);
|
||||
if (!svc) continue;
|
||||
try {
|
||||
await svc.deleteFile(key);
|
||||
} catch (err) {
|
||||
console.error(`[ASSET][ROLLBACK] Failed to delete "${key}" from "${provider}":`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── finalizeReplacementUpload ─────────────────────────────────────────────────
|
||||
//
|
||||
// Used by updateAsset() when replacing an asset's file (or a video's
|
||||
// thumbnail): the browser already PUT the new file straight to storage via a
|
||||
// presigned URL (see presignAssetUpload) — this reads back what actually
|
||||
// landed there (HeadObjectCommand, no download) instead of ever buffering the
|
||||
// file through this backend, the same strategy finalizeAssetFromStorage()
|
||||
// uses for brand-new assets.
|
||||
// Returns { file_url, storage_key, mime_type, extension, checksum, file_type, originalname }
|
||||
//
|
||||
async function finalizeReplacementUpload(storage_key, original_name, mimetype, storageProvider) {
|
||||
const svc = getProvider(storageProvider);
|
||||
if (!svc || !svc.getFileMetadata) {
|
||||
throw Object.assign(new Error("Presigned uploads are only supported for S3 storage."), { status: 400 });
|
||||
}
|
||||
|
||||
const meta = await svc.getFileMetadata(storage_key);
|
||||
const mime_type = meta.mimetype || mimetype || "application/octet-stream";
|
||||
|
||||
return {
|
||||
file_url: await svc.buildPublicUrl(storage_key),
|
||||
storage_key,
|
||||
mime_type,
|
||||
extension: resolveExtension(original_name || storage_key),
|
||||
checksum: meta.checksum,
|
||||
file_type: resolveFileType(mime_type),
|
||||
originalname: original_name || storage_key,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── applyAssetUpdate ─────────────────────────────────────────────────────────
|
||||
|
||||
async function applyAssetUpdate(asset, file, body) {
|
||||
const isThumbnailOnly = asset.file_type === "video" && !!file;
|
||||
|
||||
if (body.display_name !== undefined) asset.display_name = body.display_name;
|
||||
if (body.description !== undefined) asset.description = body.description;
|
||||
if (body.is_public !== undefined) asset.is_public = body.is_public === "true" || body.is_public === true;
|
||||
asset.updatedBy = body.updatedBy ?? null;
|
||||
|
||||
if (file) {
|
||||
if (isThumbnailOnly) {
|
||||
asset.thumbnail_url = file.file_url;
|
||||
asset.thumbnail_storage_key = file.storage_key;
|
||||
} else {
|
||||
asset.original_name = file.originalname;
|
||||
asset.file_url = file.file_url;
|
||||
asset.file_size = file.size;
|
||||
asset.mime_type = file.mime_type;
|
||||
asset.extension = file.extension;
|
||||
asset.checksum = file.checksum;
|
||||
asset.file_type = file.file_type;
|
||||
asset.storage_key = file.storage_key;
|
||||
|
||||
const parsedWidth = body.width ? parseInt(body.width) : null;
|
||||
const parsedHeight = body.height ? parseInt(body.height) : null;
|
||||
if (parsedWidth || parsedHeight) {
|
||||
asset.width = parsedWidth;
|
||||
asset.height = parsedHeight;
|
||||
asset.resolution = resolveResolution(parsedWidth, parsedHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── deleteOldFile ────────────────────────────────────────────────────────────
|
||||
|
||||
async function deleteOldFile(storageProvider, oldStorageKey, newKey) {
|
||||
if (!oldStorageKey || oldStorageKey === newKey) return;
|
||||
const svc = getProvider(storageProvider);
|
||||
if (!svc) return;
|
||||
try {
|
||||
await svc.deleteFile(oldStorageKey);
|
||||
} catch (err) {
|
||||
console.warn(`[ASSET][CLEANUP] Old file cleanup failed for "${oldStorageKey}":`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helper: hide S3 file_url from responses ──────────────────────────────────
|
||||
//
|
||||
// The raw S3 presigned/public URL is never sent to any browser.
|
||||
// Admin viewers request a short-lived stream token instead
|
||||
// (POST /api/admin/media/token → GET /api/client/media/stream/:token).
|
||||
// Chibisafe assets keep their file_url (CDN public URL, no proxy needed).
|
||||
//
|
||||
function redactS3Url(asset) {
|
||||
if (asset?.storage_provider === "s3") asset.file_url = null;
|
||||
return asset;
|
||||
}
|
||||
|
||||
// ─── attachStreamTokens ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Embeds a stream_token (+ presigned thumbnail_url) directly into each S3 row
|
||||
// so pickers/tables reading the list can render thumbnails immediately instead
|
||||
// of firing a second POST /admin/media/tokens round-trip and waiting on it.
|
||||
// storage_key is kept out of the DB attribute exclude list (unlike the rest of
|
||||
// adminExclude) purely so it's available here to sign the token — it's still
|
||||
// stripped from every row before the response goes out.
|
||||
//
|
||||
// Operates on shallow copies: `result.data` is shared with listCache, and
|
||||
// mutating those rows in place would delete storage_key from the cached
|
||||
// objects, breaking token issuance for the next request that hits the cache.
|
||||
//
|
||||
async function attachStreamTokens(rows, req) {
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const userId = req.user?.user_id;
|
||||
|
||||
return Promise.all(rows.map(async (original) => {
|
||||
const row = { ...original };
|
||||
const eligible = row.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(row.file_type);
|
||||
|
||||
if (eligible) {
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(row, userId, ip);
|
||||
row.stream_token = token;
|
||||
if (thumbnail_url) row.thumbnail_url = thumbnail_url;
|
||||
}
|
||||
|
||||
delete row.storage_key;
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getAssets = async (req, res) => {
|
||||
try {
|
||||
const key = listCacheKey(req);
|
||||
const cached = listCache.get(key);
|
||||
let result;
|
||||
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
result = cached.result;
|
||||
} else {
|
||||
result = await paginate(Asset, req, {
|
||||
excludeAttributes: LIST_QUERY_EXCLUDE,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
||||
findOptions: { where: { deletedAt: null } },
|
||||
});
|
||||
result.data = result.data.map(redactS3Url);
|
||||
listCache.set(key, { result, expiresAt: Date.now() + LIST_CACHE_TTL_MS });
|
||||
}
|
||||
|
||||
const data = await attachStreamTokens(result.data, req);
|
||||
return R.success(res, "Files retrieved.", { ...result, data });
|
||||
} catch (err) {
|
||||
console.error("[ASSET][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve files.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getAsset = async (req, res) => {
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
|
||||
|
||||
const asset = await Asset.findOne({
|
||||
where: { asset_id: assetId, ...notDeleted },
|
||||
// storage_key stays selected here (unlike the list query) so it's
|
||||
// available below to sign a stream token — stripped before the response.
|
||||
attributes: { exclude: ["storage_bucket"] },
|
||||
include: [
|
||||
{ model: mdl_Users, as: "creator", attributes: ["user_id", "email", "personal_info"], foreignKey: "createdBy" },
|
||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "email", "personal_info"], foreignKey: "updatedBy" },
|
||||
],
|
||||
});
|
||||
|
||||
if (!asset) return R.error(res, "File not found.", 404);
|
||||
|
||||
const json = asset.toJSON();
|
||||
|
||||
// Falls back to email when full_name hasn't been filled in — better than
|
||||
// surfacing the raw numeric user_id in the admin UI.
|
||||
if (json.creator) {
|
||||
json.creator = {
|
||||
user_id: json.creator.user_id,
|
||||
full_name: json.creator.personal_info?.name?.full_name || json.creator.email || null,
|
||||
};
|
||||
}
|
||||
if (json.updater) {
|
||||
json.updater = {
|
||||
user_id: json.updater.user_id,
|
||||
full_name: json.updater.personal_info?.name?.full_name || json.updater.email || null,
|
||||
};
|
||||
}
|
||||
|
||||
if (json.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(json.file_type)) {
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(json, req.user?.user_id, ip);
|
||||
json.stream_token = token;
|
||||
if (thumbnail_url) json.thumbnail_url = thumbnail_url;
|
||||
}
|
||||
delete json.storage_key;
|
||||
|
||||
redactS3Url(json);
|
||||
return R.success(res, "File retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
console.error("[ASSET][GET ONE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPLOAD (shared core) ──────────────────────────────────────────────────────
|
||||
//
|
||||
// ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
// │ PRESIGNED-UPLOAD STRATEGY │
|
||||
// │ │
|
||||
// │ The browser already PUT the file's bytes straight to storage via a │
|
||||
// │ presigned URL (see presignAssetUpload below) — this backend never │
|
||||
// │ buffers or even touches them (this is what removes the old 500MB │
|
||||
// │ multer-memoryStorage RAM ceiling entirely, regardless of file size). │
|
||||
// │ Finalizing an asset from an already-uploaded object is just: │
|
||||
// │ • HeadObjectCommand → real file_size/mime_type/checksum (=ETag) │
|
||||
// │ • ffprobe by URL → video/audio metadata only, no download │
|
||||
// │ • BEGIN → Asset.create() → COMMIT │
|
||||
// │ │
|
||||
// │ On any error: rollbackUploads([{ key, provider }]) deletes the │
|
||||
// │ already-uploaded object(s) — same cleanup as before, just always │
|
||||
// │ covering both file + thumbnail upfront, since both already exist in │
|
||||
// │ storage by the time this runs (the browser uploaded them first). │
|
||||
// └─────────────────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// Thumbnails are optional for both video and audio — a video/audio asset can
|
||||
// land with thumbnail_url null and pick one up later via the existing
|
||||
// "thumbnail-only" path in updateAsset().
|
||||
//
|
||||
async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, original_name, body, user }) {
|
||||
const {
|
||||
display_name,
|
||||
description,
|
||||
is_public = false,
|
||||
storage_provider = "s3",
|
||||
storage_bucket,
|
||||
createdBy,
|
||||
} = body;
|
||||
|
||||
const uploadedFiles = [{ key: storage_key, provider: storage_provider }];
|
||||
if (thumbnail_storage_key) uploadedFiles.push({ key: thumbnail_storage_key, provider: storage_provider });
|
||||
|
||||
try {
|
||||
if (!storage_key) throw Object.assign(new Error("storage_key is required."), { status: 400 });
|
||||
if (!createdBy) throw Object.assign(new Error("createdBy is required."), { status: 400 });
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (!svc || !svc.getFileMetadata) {
|
||||
throw Object.assign(new Error("Presigned uploads are only supported for S3 storage."), { status: 400 });
|
||||
}
|
||||
|
||||
let meta;
|
||||
try {
|
||||
meta = await svc.getFileMetadata(storage_key);
|
||||
} catch {
|
||||
throw Object.assign(new Error("Uploaded file not found in storage — the upload may have failed or expired."), { status: 400 });
|
||||
}
|
||||
|
||||
// S3's own Content-Type is authoritative when present, but browsers only
|
||||
// send one automatically when the File object's own .type is non-empty —
|
||||
// fall back to whatever the client reported at presign time, and finally
|
||||
// to a generic default, rather than ever letting a NOT NULL column see
|
||||
// null here (mime_type also drives file_type below, so a null here would
|
||||
// misclassify the asset entirely, not just leave a field blank).
|
||||
const mime_type = meta.mimetype || body.mimetype || "application/octet-stream";
|
||||
const file_type = resolveFileType(mime_type);
|
||||
const extension = resolveExtension(original_name || storage_key);
|
||||
const file_url = await svc.buildPublicUrl(storage_key);
|
||||
|
||||
// ── ffprobe (video/audio only) ────────────────────────────────────────────
|
||||
|
||||
let width = null, height = null, resolution = null;
|
||||
let duration = null, frame_rate = null, bitrate = null;
|
||||
let video_codec = null, audio_codec = null;
|
||||
let thumbnail_url = null;
|
||||
|
||||
if (file_type === "video" || file_type === "audio") {
|
||||
const probeUrl = await svc.getSignedDownloadUrl(storage_key);
|
||||
const videoMeta = await extractVideoMeta({ url: probeUrl });
|
||||
width = videoMeta.width;
|
||||
height = videoMeta.height;
|
||||
resolution = videoMeta.resolution;
|
||||
duration = videoMeta.duration;
|
||||
frame_rate = videoMeta.frame_rate;
|
||||
bitrate = videoMeta.bitrate;
|
||||
video_codec = videoMeta.video_codec;
|
||||
audio_codec = videoMeta.audio_codec;
|
||||
|
||||
if (thumbnail_storage_key) {
|
||||
thumbnail_url = await svc.buildPublicUrl(thumbnail_storage_key);
|
||||
} else if (file_type === "video") {
|
||||
// No client-provided thumbnail — grab a frame from the video itself so
|
||||
// the asset doesn't sit with no preview at all in every picker/library
|
||||
// grid. Best-effort: a failure here must not fail the whole upload.
|
||||
let framePath = null;
|
||||
try {
|
||||
framePath = await ffmpegSvc.extractFrameThumbnail(probeUrl, duration);
|
||||
const uploaded = await svc.uploadStream({
|
||||
stream: fs.createReadStream(framePath),
|
||||
originalname: `${(original_name || "thumb").replace(/\.[^.]+$/, "")}.jpg`,
|
||||
mimetype: "image/jpeg",
|
||||
ownerType: "thumbnail", // → thumbnails/ prefix, same as manually-uploaded thumbnails
|
||||
});
|
||||
thumbnail_storage_key = uploaded.uuid;
|
||||
thumbnail_url = uploaded.url;
|
||||
uploadedFiles.push({ key: thumbnail_storage_key, provider: storage_provider }); // rollback cleanup on later failure
|
||||
} catch (err) {
|
||||
console.warn(`[ASSET][THUMBNAIL] Auto-generate failed for "${storage_key}":`, err.message);
|
||||
// leave thumbnail_url null — same fallback as before, admin can add one manually later
|
||||
} finally {
|
||||
if (framePath) fs.promises.unlink(framePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
const parsedWidth = body.width ? parseInt(body.width) : null;
|
||||
const parsedHeight = body.height ? parseInt(body.height) : null;
|
||||
width = parsedWidth;
|
||||
height = parsedHeight;
|
||||
resolution = resolveResolution(parsedWidth, parsedHeight);
|
||||
}
|
||||
|
||||
// ── DB insert ──────────────────────────────────────────────────────────────
|
||||
|
||||
// .mov/.mkv videos load slowly in-browser (moov/Cues index at the end of
|
||||
// the file) — flag them for the background remux job (see
|
||||
// assetTranscode.service.js) fired below, right after commit.
|
||||
const needsTranscode = storage_provider === "s3" && file_type === "video" && ffmpegSvc.needsRemux(extension);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const asset = await Asset.create({
|
||||
original_name: original_name || storage_key,
|
||||
display_name: display_name || original_name || storage_key,
|
||||
file_url,
|
||||
file_size: meta.size,
|
||||
mime_type,
|
||||
extension,
|
||||
checksum: meta.checksum,
|
||||
file_type,
|
||||
width,
|
||||
height,
|
||||
resolution,
|
||||
duration,
|
||||
frame_rate,
|
||||
bitrate,
|
||||
video_codec,
|
||||
audio_codec,
|
||||
thumbnail_url,
|
||||
thumbnail_storage_key,
|
||||
description,
|
||||
storage_provider,
|
||||
storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null,
|
||||
storage_key,
|
||||
is_public,
|
||||
createdBy,
|
||||
transcode_status: needsTranscode ? "pending" : "none",
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
|
||||
|
||||
if (needsTranscode) {
|
||||
assetTranscode.transcodeAsset(asset).catch((err) => {
|
||||
console.error("[ASSET][TRANSCODE] Background remux failed to start:", err.message);
|
||||
});
|
||||
}
|
||||
|
||||
return asset;
|
||||
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* connection gone */ }
|
||||
await rollbackUploads(uploadedFiles);
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
await rollbackUploads(uploadedFiles);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PRESIGN UPLOAD ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mints a short-lived presigned PUT URL so the browser can upload the file's
|
||||
// bytes directly to storage — this backend never buffers them. Called once
|
||||
// for the main file, and once more for a thumbnail if the admin picked one
|
||||
// (see s3.service.js#presignUpload for the key-naming convention).
|
||||
//
|
||||
exports.presignAssetUpload = async (req, res) => {
|
||||
try {
|
||||
const { filename, mimetype, file_type, size = 0, storage_provider = "s3" } = req.body;
|
||||
if (!filename) return R.error(res, "filename is required.", 400);
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (!svc || !svc.presignUpload) {
|
||||
return R.error(res, "Presigned uploads are only supported for S3 storage.", 400);
|
||||
}
|
||||
|
||||
const ownerType = file_type || resolveFileType(mimetype || "") || "document";
|
||||
// Result is either { key, uploadUrl } or, above the single-PUT ceiling,
|
||||
// { key, multipart: true, uploadId, partSize, parts } — see
|
||||
// s3.service.js#presignUpload. The client branches on `multipart`.
|
||||
const presigned = await svc.presignUpload(filename, ownerType, Number(size) || 0);
|
||||
return R.success(res, "Presigned URL generated.", presigned);
|
||||
|
||||
} catch (err) {
|
||||
console.error("[ASSET][PRESIGN]", err);
|
||||
return R.error(res, "Could not generate upload URL.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── COMPLETE / ABORT MULTIPART ─────────────────────────────────────────────
|
||||
//
|
||||
// Only used above presignAssetUpload's single-PUT ceiling (see
|
||||
// s3.service.js's MULTIPART_THRESHOLD) — the browser PUTs every part
|
||||
// directly, then calls complete-multipart with the ETags each part's PUT
|
||||
// response returned. abort-multipart is the failure-path cleanup (a part
|
||||
// exhausted its retries, or the admin cancelled) so an abandoned multipart
|
||||
// upload doesn't linger as orphaned storage forever.
|
||||
//
|
||||
exports.completeMultipartAssetUpload = async (req, res) => {
|
||||
try {
|
||||
const { storage_key, uploadId, parts, storage_provider = "s3" } = req.body;
|
||||
if (!storage_key || !uploadId || !Array.isArray(parts) || !parts.length) {
|
||||
return R.error(res, "storage_key, uploadId, and parts are required.", 400);
|
||||
}
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (!svc || !svc.completeMultipartUpload) {
|
||||
return R.error(res, "Multipart uploads are only supported for S3 storage.", 400);
|
||||
}
|
||||
|
||||
await svc.completeMultipartUpload(storage_key, uploadId, parts);
|
||||
return R.success(res, "Multipart upload completed.", {});
|
||||
|
||||
} catch (err) {
|
||||
console.error("[ASSET][COMPLETE MULTIPART]", err);
|
||||
return R.error(res, "Could not complete multipart upload.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.abortMultipartAssetUpload = async (req, res) => {
|
||||
try {
|
||||
const { storage_key, uploadId, storage_provider = "s3" } = req.body;
|
||||
if (!storage_key || !uploadId) return R.error(res, "storage_key and uploadId are required.", 400);
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (svc?.abortMultipartUpload) {
|
||||
try {
|
||||
await svc.abortMultipartUpload(storage_key, uploadId);
|
||||
} catch (err) {
|
||||
// Best-effort, same tolerance as rollbackUploads() — an already-gone
|
||||
// or already-completed upload isn't worth failing the request over.
|
||||
console.error(`[ASSET][ABORT MULTIPART] Failed to abort "${storage_key}":`, err.message);
|
||||
}
|
||||
}
|
||||
return R.success(res, "Multipart upload aborted.", {});
|
||||
|
||||
} catch (err) {
|
||||
console.error("[ASSET][ABORT MULTIPART]", err);
|
||||
return R.error(res, "Could not abort multipart upload.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPLOAD (finalize) ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Called once the browser's direct-to-storage PUT(s) have completed. Body is
|
||||
// plain JSON — no file bytes here, just the storage key(s) presignAssetUpload
|
||||
// handed back plus asset metadata. Also the endpoint the bulk queue
|
||||
// (UploadQueueContext.jsx) calls once per file, reusing this single-asset
|
||||
// path instead of a separate batch endpoint.
|
||||
//
|
||||
exports.uploadAsset = async (req, res) => {
|
||||
try {
|
||||
const { storage_key, thumbnail_storage_key, original_name } = req.body;
|
||||
const asset = await finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, original_name, body: req.body, user: req.user });
|
||||
invalidateListCache();
|
||||
return R.success(res, "File uploaded.", { data: asset }, 201);
|
||||
|
||||
} catch (err) {
|
||||
console.error("[ASSET][UPLOAD]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateAsset = async (req, res) => {
|
||||
let newUpload = null; // { key, provider }
|
||||
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
|
||||
|
||||
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
||||
if (!asset) return R.error(res, "File not found.", 404);
|
||||
|
||||
// Browser already PUT the replacement file straight to storage via a
|
||||
// presigned URL (see presignAssetUpload) — this is plain JSON, no
|
||||
// multer/file buffer involved, same pattern as POST /admin/assets.
|
||||
const { storage_key, original_name, mimetype } = req.body;
|
||||
const isVideo = asset.file_type === "video";
|
||||
const isDocument = asset.file_type === "document";
|
||||
|
||||
if (isDocument && storage_key) return R.error(res, "Document files cannot be replaced.", 400);
|
||||
if (isVideo && storage_key && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new file instead.", 400);
|
||||
|
||||
const storageProvider = asset.storage_provider;
|
||||
const usesProvider = ["chibisafe", "s3"].includes(storageProvider);
|
||||
const oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key;
|
||||
|
||||
// ── Phase 1: Upload ───────────────────────────────────────────────────────
|
||||
|
||||
let uploaded = null;
|
||||
|
||||
if (storage_key && usesProvider) {
|
||||
uploaded = await finalizeReplacementUpload(storage_key, original_name, mimetype, storageProvider);
|
||||
newUpload = { key: uploaded.storage_key, provider: storageProvider };
|
||||
}
|
||||
|
||||
// ── Phase 2: DB update ────────────────────────────────────────────────────
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await applyAssetUpdate(asset, uploaded, req.body);
|
||||
await asset.save({ transaction: t });
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
if (newUpload) await rollbackUploads([newUpload]);
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
// ── Phase 3: Cleanup old file ─────────────────────────────────────────────
|
||||
|
||||
if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key);
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "File updated.", { data: asset });
|
||||
|
||||
} catch (err) {
|
||||
if (newUpload) await rollbackUploads([newUpload]);
|
||||
console.error("[ASSET][UPDATE]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveAsset = async (req, res) => {
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
|
||||
|
||||
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
||||
if (!asset) return R.error(res, "File not found.", 404);
|
||||
|
||||
await asset.update({ deletedBy: req.body.deletedBy ?? null });
|
||||
await asset.destroy();
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "File archived.");
|
||||
} catch (err) {
|
||||
console.error("[ASSET][ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveAssets = async (req, res) => {
|
||||
try {
|
||||
const { ids, deletedBy } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids }, ...notDeleted } });
|
||||
if (!assets.length) return R.error(res, "No files found.", 404);
|
||||
|
||||
const activeIds = assets.map((a) => a.asset_id);
|
||||
|
||||
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
|
||||
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
|
||||
return R.success(res, `${activeIds.length} file(s) archived.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ASSET][BULK ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreAsset = async (req, res) => {
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
|
||||
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
||||
if (!asset) return R.error(res, "File not found.", 404);
|
||||
if (!asset.deletedAt) return R.error(res, "File is not archived.", 400);
|
||||
|
||||
await asset.restore();
|
||||
await asset.update({ deletedBy: null });
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "File restored.", { data: asset });
|
||||
} catch (err) {
|
||||
console.error("[ASSET][RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreAssets = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!assets.length) return R.error(res, "No files found.", 404);
|
||||
|
||||
const archivedAssets = assets.filter((a) => a.deletedAt);
|
||||
if (!archivedAssets.length) return R.error(res, "All selected files are already active.", 400);
|
||||
|
||||
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
||||
|
||||
await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } });
|
||||
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false });
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} file(s) restored.`, {
|
||||
restored_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ASSET][BULK RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (single) ─────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAsset = async (req, res) => {
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
|
||||
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
||||
if (!asset) return R.error(res, "File not found.", 404);
|
||||
if (!asset.deletedAt) return R.error(res, "File must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
const { storage_provider, storage_key, thumbnail_storage_key } = asset;
|
||||
|
||||
await asset.destroy({ force: true });
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (svc) {
|
||||
if (storage_key) {
|
||||
try { await svc.deleteFile(storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${storage_key}":`, err.message); }
|
||||
}
|
||||
if (thumbnail_storage_key) {
|
||||
try { await svc.deleteFile(thumbnail_storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${thumbnail_storage_key}":`, err.message); }
|
||||
}
|
||||
}
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'permanently_delete_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "File permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[ASSET][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAssets = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!assets.length) return R.error(res, "No files found.", 404);
|
||||
|
||||
const archivedAssets = assets.filter((a) => a.deletedAt);
|
||||
if (!archivedAssets.length) return R.error(res, "All selected files must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
||||
|
||||
await Asset.destroy({ where: { asset_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
for (const asset of archivedAssets) {
|
||||
const svc = getProvider(asset.storage_provider);
|
||||
if (!svc) continue;
|
||||
if (asset.storage_key) {
|
||||
try { await svc.deleteFile(asset.storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${asset.storage_key}":`, err.message); }
|
||||
}
|
||||
if (asset.thumbnail_storage_key) {
|
||||
try { await svc.deleteFile(asset.thumbnail_storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${asset.thumbnail_storage_key}":`, err.message); }
|
||||
}
|
||||
}
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} file(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ASSET][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedAssets = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Asset, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
||||
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
||||
});
|
||||
result.data = result.data.map(redactS3Url);
|
||||
return R.success(res, "Archived files retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[ASSET][GET ARCHIVED]", err);
|
||||
return R.error(res, "Could not retrieve archived files.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getAssetFieldValues = getFieldValues(Asset, "ASSET");
|
||||
@@ -0,0 +1,219 @@
|
||||
'use strict';
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Category = require('../../models/courses/categories.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { CourseProductCategory } = require('../../models/courses/courses.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const {
|
||||
excludeAttributes: categoriesExclude,
|
||||
jsonbSchemas: categoriesSchemas,
|
||||
} = require('../../models/courses/categories.attributes');
|
||||
|
||||
const slugify = (str) =>
|
||||
str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
|
||||
exports.getCategories = async (req, res) => {
|
||||
try {
|
||||
const archived = req.query.archived === 'true';
|
||||
const result = await paginate(mdl_Category, req, {
|
||||
excludeAttributes: categoriesExclude,
|
||||
jsonbSchemas: categoriesSchemas,
|
||||
context: archived ? 'archived' : 'list',
|
||||
auditOptions: { mdl_Users, parentAlias: 'Category' },
|
||||
findOptions: archived
|
||||
? { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }
|
||||
: {},
|
||||
});
|
||||
return R.success(res, 'Categories retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][GET ALL]', err);
|
||||
return R.error(res, 'Could not retrieve categories.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getCategory = async (req, res) => {
|
||||
try {
|
||||
const row = await mdl_Category.findByPk(req.params.id, { paranoid: false });
|
||||
if (!row) return R.error(res, 'Category not found.', 404);
|
||||
return R.success(res, 'Category retrieved.', row);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][GET ONE]', err);
|
||||
return R.error(res, 'Could not retrieve category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.createCategory = async (req, res) => {
|
||||
try {
|
||||
const { name, description, is_active } = req.body;
|
||||
if (!name) return R.error(res, 'name is required.', 400);
|
||||
|
||||
const slug = slugify(name);
|
||||
const row = await mdl_Category.create({
|
||||
name, slug, description: description ?? null, is_active: is_active ?? true,
|
||||
createdBy: req.body.createdBy ?? req.user?.user_id ?? null,
|
||||
});
|
||||
logActivity(req.user?.user_id, 'create_category', { entityType: 'category', entityId: row.category_id, details: { name: row.name } });
|
||||
return R.success(res, 'Category created.', row, 201);
|
||||
} catch (err) {
|
||||
if (err.name === 'SequelizeUniqueConstraintError')
|
||||
return R.error(res, 'A category with that name already exists.', 409);
|
||||
console.error('[ADMIN][CATEGORIES][CREATE]', err);
|
||||
return R.error(res, 'Could not create category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateCategory = async (req, res) => {
|
||||
try {
|
||||
const row = await mdl_Category.findByPk(req.params.id);
|
||||
if (!row) return R.error(res, 'Category not found.', 404);
|
||||
|
||||
const { name, description, is_active } = req.body;
|
||||
const slug = name ? slugify(name) : row.slug;
|
||||
await row.update({
|
||||
name: name ?? row.name, slug, description: description ?? row.description, is_active: is_active ?? row.is_active,
|
||||
updatedBy: req.body.updatedBy ?? req.user?.user_id ?? null,
|
||||
});
|
||||
logActivity(req.user?.user_id, 'update_category', { entityType: 'category', entityId: row.category_id });
|
||||
return R.success(res, 'Category updated.', row);
|
||||
} catch (err) {
|
||||
if (err.name === 'SequelizeUniqueConstraintError')
|
||||
return R.error(res, 'A category with that name already exists.', 409);
|
||||
console.error('[ADMIN][CATEGORIES][UPDATE]', err);
|
||||
return R.error(res, 'Could not update category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.archiveCategory = async (req, res) => {
|
||||
try {
|
||||
const row = await mdl_Category.findByPk(req.params.id);
|
||||
if (!row) return R.error(res, 'Category not found.', 404);
|
||||
await row.update({ deletedBy: req.user?.user_id ?? null, is_active: false });
|
||||
await row.destroy();
|
||||
logActivity(req.user?.user_id, 'archive_category', { entityType: 'category', entityId: row.category_id });
|
||||
return R.success(res, 'Category archived.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][ARCHIVE]', err);
|
||||
return R.error(res, 'Could not archive category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.restoreCategory = async (req, res) => {
|
||||
try {
|
||||
const row = await mdl_Category.findByPk(req.params.id, { paranoid: false });
|
||||
if (!row) return R.error(res, 'Category not found.', 404);
|
||||
if (!row.deletedAt) return R.error(res, 'Category is not archived.', 400);
|
||||
await row.restore();
|
||||
await row.update({ deletedBy: null, is_active: true });
|
||||
logActivity(req.user?.user_id, 'restore_category', { entityType: 'category', entityId: row.category_id });
|
||||
return R.success(res, 'Category restored.', row);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][RESTORE]', err);
|
||||
return R.error(res, 'Could not restore category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkArchiveCategories = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No category IDs provided.', 400);
|
||||
|
||||
const rows = await mdl_Category.findAll({ where: { id: ids } });
|
||||
if (!rows.length) return R.error(res, 'No categories found.', 404);
|
||||
|
||||
const activeIds = rows.map((r) => r.id);
|
||||
await Promise.all(rows.map((r) => r.update({ deletedBy: req.user?.user_id ?? null, is_active: false })));
|
||||
await mdl_Category.destroy({ where: { id: activeIds } });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_archive_categories', { entityType: 'category', details: { ids: activeIds, count: activeIds.length } });
|
||||
return R.success(res, `${activeIds.length} categor${activeIds.length !== 1 ? 'ies' : 'y'} archived.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][BULK ARCHIVE]', err);
|
||||
return R.error(res, 'Could not archive categories.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getCategoryPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const row = await mdl_Category.findByPk(req.params.id, { paranoid: false });
|
||||
if (!row) return R.error(res, 'Category not found.', 404);
|
||||
const course_count = await CourseProductCategory.count({ where: { category_id: req.params.id } });
|
||||
return R.success(res, 'Category permanent-delete impact retrieved.', { course_count });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][PERMANENT DELETE IMPACT]', err);
|
||||
return R.error(res, 'Could not retrieve category permanent-delete impact.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteCategory = async (req, res) => {
|
||||
try {
|
||||
const row = await mdl_Category.findByPk(req.params.id, { paranoid: false });
|
||||
if (!row) return R.error(res, 'Category not found.', 404);
|
||||
if (!row.deletedAt) return R.error(res, 'Category must be archived before it can be permanently deleted.', 400);
|
||||
// course_product_categories.category_id has no DB-level cascade (only course_id does) —
|
||||
// clean up the junction rows explicitly or they'd be left orphaned.
|
||||
await CourseProductCategory.destroy({ where: { category_id: row.id } });
|
||||
await row.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_category', { entityType: 'category', entityId: row.id, details: { name: row.name } });
|
||||
return R.success(res, 'Category permanently deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][PERMANENT DELETE]', err);
|
||||
return R.error(res, 'Could not permanently delete category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkRestoreCategories = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No category IDs provided.', 400);
|
||||
|
||||
const rows = await mdl_Category.findAll({ where: { id: ids }, paranoid: false });
|
||||
if (!rows.length) return R.error(res, 'No categories found.', 404);
|
||||
|
||||
const archivedRows = rows.filter((r) => r.deletedAt);
|
||||
if (!archivedRows.length) return R.error(res, 'All selected categories are already active.', 400);
|
||||
|
||||
const archivedIds = archivedRows.map((r) => r.id);
|
||||
await mdl_Category.restore({ where: { id: archivedIds } });
|
||||
await mdl_Category.update({ deletedBy: null, is_active: true }, { where: { id: archivedIds }, paranoid: false });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_restore_categories', { entityType: 'category', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} categor${archivedIds.length !== 1 ? 'ies' : 'y'} restored.`, {
|
||||
restored_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][BULK RESTORE]', err);
|
||||
return R.error(res, 'Could not restore categories.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteCategories = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No category IDs provided.', 400);
|
||||
|
||||
const rows = await mdl_Category.findAll({ where: { id: ids }, paranoid: false });
|
||||
if (!rows.length) return R.error(res, 'No categories found.', 404);
|
||||
|
||||
const archivedRows = rows.filter((r) => r.deletedAt);
|
||||
if (!archivedRows.length) return R.error(res, 'All selected categories must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const archivedIds = archivedRows.map((r) => r.id);
|
||||
await CourseProductCategory.destroy({ where: { category_id: archivedIds } });
|
||||
await mdl_Category.destroy({ where: { id: archivedIds }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_categories', { entityType: 'category', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} categor${archivedIds.length !== 1 ? 'ies' : 'y'} permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][BULK PERMANENT DELETE]', err);
|
||||
return R.error(res, 'Could not permanently delete categories.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: completion_requirements.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin CRUD for CompletionRequirement rows — what counts as "complete" for a
|
||||
* given course/unit/lesson. One shared implementation per entity_type, mounted at
|
||||
* nested routes (routes/admin/courses.routes.js) AND standalone-library routes
|
||||
* (routes/admin/units.routes.js, routes/admin/lessons.routes.js), matching the
|
||||
* existing dual-mount convention already used for quiz routes.
|
||||
*
|
||||
* getXRequirements — GET, list configured rows for one entity.
|
||||
* syncXRequirements — PUT, soft-delete-and-recreate the full set for one entity (same pattern
|
||||
* as controllers/admin/task.controller.js#updateTask, proactively applying
|
||||
* its documented fix: strip requirement_id/timestamps before bulkCreate so
|
||||
* the fresh rows never collide with the just-soft-deleted PKs). Validates
|
||||
* each row's `type` against the registry's validEntityTypes for this
|
||||
* entity_type, and rejects duplicate types on one entity — validation the
|
||||
* Task requirements system doesn't have.
|
||||
*
|
||||
* When a pass_quiz requirement is added/removed on a unit/course, the corresponding
|
||||
* UnitQuiz.is_required / CourseAssessment.is_required is flipped in lockstep, so the
|
||||
* existing sequential quiz-lock (which reads is_required) and the new completion
|
||||
* requirement never disagree with each other.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 14, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
const sequelize = require('../../config/db.config');
|
||||
const CompletionRequirement = require('../../models/courses/completion_requirement.mdl');
|
||||
const { Course, Unit, Lesson, UnitQuiz, CourseAssessment } = require('../../models/courses/courses.associations');
|
||||
const { VALID_ENTITY_TYPES } = require('../../utils/courses/completion_requirements.registry');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
const ENTITY_MODELS = {
|
||||
course: { model: Course, pk: 'course_id', label: 'Course' },
|
||||
unit: { model: Unit, pk: 'unit_id', label: 'Unit' },
|
||||
lesson: { model: Lesson, pk: 'lesson_id', label: 'Lesson' },
|
||||
};
|
||||
|
||||
async function fetchRequirements(entityType, entityId, t) {
|
||||
return CompletionRequirement.findAll({
|
||||
where: { entity_type: entityType, entity_id: entityId },
|
||||
order: [['order', 'ASC']],
|
||||
transaction: t,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getRequirements(entityType, entityId, req, res) {
|
||||
try {
|
||||
const { model, pk, label } = ENTITY_MODELS[entityType];
|
||||
const entity = await model.findOne({ where: { [pk]: entityId, ...notDeleted } });
|
||||
if (!entity) return R.error(res, `${label} not found.`, 404);
|
||||
|
||||
const rows = await fetchRequirements(entityType, entityId);
|
||||
return R.success(res, 'Completion requirements retrieved.', rows);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][COMPLETION REQUIREMENTS][GET]', err);
|
||||
return R.error(res, 'Could not retrieve completion requirements.', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PUT (soft-delete-and-recreate) ──────────────────────────────────────────
|
||||
|
||||
async function syncRequirements(entityType, entityId, req, res) {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { model, pk, label } = ENTITY_MODELS[entityType];
|
||||
const entity = await model.findOne({ where: { [pk]: entityId, ...notDeleted }, transaction: t });
|
||||
if (!entity) {
|
||||
await t.rollback();
|
||||
return R.error(res, `${label} not found.`, 404);
|
||||
}
|
||||
|
||||
const requirements = Array.isArray(req.body.requirements) ? req.body.requirements : [];
|
||||
|
||||
// Server-side validation the Task requirements system doesn't have: type must be a
|
||||
// known type, valid for this entity_type, and configured at most once per entity.
|
||||
const seenTypes = new Set();
|
||||
for (const r of requirements) {
|
||||
const allowedEntityTypes = VALID_ENTITY_TYPES[r.type];
|
||||
if (!allowedEntityTypes) {
|
||||
await t.rollback();
|
||||
return R.error(res, `Unknown requirement type "${r.type}".`, 400);
|
||||
}
|
||||
if (!allowedEntityTypes.includes(entityType)) {
|
||||
await t.rollback();
|
||||
return R.error(res, `"${r.type}" cannot be configured on a ${entityType}.`, 400);
|
||||
}
|
||||
if (seenTypes.has(r.type)) {
|
||||
await t.rollback();
|
||||
return R.error(res, `Duplicate "${r.type}" requirement — only one per entity is allowed.`, 400);
|
||||
}
|
||||
seenTypes.add(r.type);
|
||||
}
|
||||
|
||||
// Soft-delete existing rows, then bulkCreate the replacement set. Requirement_id and
|
||||
// timestamps are stripped from each incoming row (server-owned) so the fresh insert
|
||||
// never collides with the just-soft-deleted row still occupying that requirement_id PK —
|
||||
// see controllers/admin/task.controller.js#updateTask for the bug this proactively avoids.
|
||||
await CompletionRequirement.destroy({
|
||||
where: { entity_type: entityType, entity_id: entityId },
|
||||
force: false,
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
if (requirements.length) {
|
||||
const rows = requirements.map((r, i) => {
|
||||
const { requirement_id, createdAt, updatedAt, deletedAt, entity_type, entity_id, ...rest } = r;
|
||||
return {
|
||||
...rest,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
order: rest.order ?? i,
|
||||
min_percent: rest.type === 'watch_percent'
|
||||
? Math.min(100, Math.max(1, Math.round(Number(rest.min_percent)) || 100))
|
||||
: null,
|
||||
button_label: rest.type === 'manual_complete' ? (rest.button_label || null) : null,
|
||||
is_required: rest.is_required ?? true,
|
||||
createdBy: req.user.user_id,
|
||||
updatedBy: req.user.user_id,
|
||||
};
|
||||
});
|
||||
await CompletionRequirement.bulkCreate(rows, { transaction: t });
|
||||
}
|
||||
|
||||
// Reconcile the pass_quiz requirement with the pre-existing UnitQuiz/CourseAssessment
|
||||
// is_required flag, so ModifyQuiz.jsx's "required to proceed" toggle and this new
|
||||
// completion-requirements editor never disagree about the same underlying boolean.
|
||||
const hasPassQuiz = requirements.some((r) => r.type === 'pass_quiz');
|
||||
if (entityType === 'unit') {
|
||||
await UnitQuiz.update({ is_required: hasPassQuiz }, { where: { unit_id: entityId }, transaction: t });
|
||||
} else if (entityType === 'course') {
|
||||
await CourseAssessment.update({ is_required: hasPassQuiz }, { where: { course_id: entityId }, transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const rows = await fetchRequirements(entityType, entityId);
|
||||
return R.success(res, 'Completion requirements updated.', rows);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][COMPLETION REQUIREMENTS][SYNC]', err);
|
||||
return R.error(res, 'Could not update completion requirements.', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Route-bound exports ──────────────────────────────────────────────────────
|
||||
|
||||
exports.getCourseRequirements = (req, res) => getRequirements('course', req.params.courseId, req, res);
|
||||
exports.syncCourseRequirements = (req, res) => syncRequirements('course', req.params.courseId, req, res);
|
||||
|
||||
exports.getUnitRequirements = (req, res) => getRequirements('unit', req.params.unitId, req, res);
|
||||
exports.syncUnitRequirements = (req, res) => syncRequirements('unit', req.params.unitId, req, res);
|
||||
|
||||
exports.getLessonRequirements = (req, res) => getRequirements('lesson', req.params.lessonId, req, res);
|
||||
exports.syncLessonRequirements = (req, res) => syncRequirements('lesson', req.params.lessonId, req, res);
|
||||
@@ -0,0 +1,240 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: course_reading_progress.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin-facing endpoints for viewing course reading progress.
|
||||
*
|
||||
* GET /:courseId/reading-progress
|
||||
* → one entry per user who has touched the course, with lesson/unit counts aggregated
|
||||
*
|
||||
* GET /:courseId/reading-progress/users/:userId
|
||||
* → full lesson + unit breakdown for a single user (loaded on row expand)
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 21, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const R = require('../../utils/response.util');
|
||||
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
|
||||
const {
|
||||
Course, Unit, Lesson, CourseUnit,
|
||||
UnitQuiz, CourseAssessment, QuizAttempt,
|
||||
} = require('../../models/courses/courses.associations');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
|
||||
const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// =============================================================================
|
||||
// ── LIST — all users who touched this course ──────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// GET /admin/courses/:courseId/reading-progress
|
||||
// Returns one summary row per user. Lesson + unit counts are derived by querying
|
||||
// the course structure server-side so the totals are always accurate.
|
||||
|
||||
exports.getCourseReadingProgress = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
|
||||
const course = await Course.findOne({
|
||||
where: { course_id: courseId, ...notDeleted },
|
||||
attributes: ['course_id'],
|
||||
});
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
|
||||
// Count total lessons and units in the course (structure totals via junctions)
|
||||
const [unitIds, lessons_total] = await Promise.all([
|
||||
getCourseUnitIds(courseId),
|
||||
countCourseLessons(courseId),
|
||||
]);
|
||||
|
||||
const units_total = unitIds.length;
|
||||
|
||||
// All progress rows for this course, grouped per user
|
||||
const rows = await CourseReadingProgress.findAll({
|
||||
where: { course_id: courseId },
|
||||
attributes: ['user_id', 'reference_id', 'type', 'status', 'last_accessed_at'],
|
||||
order: [['last_accessed_at', 'DESC']],
|
||||
});
|
||||
|
||||
if (!rows.length) return R.success(res, 'No reading progress for this course yet.', []);
|
||||
|
||||
// Aggregate per user
|
||||
const userIds = [...new Set(rows.map((r) => r.user_id))];
|
||||
|
||||
const users = await mdl_Users.findAll({
|
||||
where: { user_id: userIds },
|
||||
attributes: ['user_id', 'email', 'personal_info'],
|
||||
});
|
||||
const userMap = Object.fromEntries(users.map((u) => [u.user_id, u]));
|
||||
|
||||
// Quiz/assessment gating — mirrors controllers/client/course_reading_progress
|
||||
// .controller.js#getMyInProgressCourses: a unit quiz or course assessment that
|
||||
// exists but hasn't been passed yet is why a user with 1/1 lessons read can still
|
||||
// be 'in_progress'. Batched once per course across all users in this list.
|
||||
const unitQuizzes = unitIds.length
|
||||
? await UnitQuiz.findAll({ where: { unit_id: unitIds, ...notDeleted }, attributes: ['quiz_id'] })
|
||||
: [];
|
||||
const quizIds = unitQuizzes.map((q) => q.quiz_id);
|
||||
const assessment = await CourseAssessment.findOne({
|
||||
where: { course_id: courseId },
|
||||
attributes: ['assessment_id'],
|
||||
});
|
||||
|
||||
const passedQuizIdsByUser = new Map();
|
||||
const passedAssessmentUserIds = new Set();
|
||||
if (quizIds.length || assessment) {
|
||||
const passedAttempts = await QuizAttempt.findAll({
|
||||
where: {
|
||||
user_id: userIds,
|
||||
passed: true,
|
||||
[Op.or]: [
|
||||
...(quizIds.length ? [{ quiz_id: quizIds }] : []),
|
||||
...(assessment ? [{ assessment_id: assessment.assessment_id }] : []),
|
||||
],
|
||||
},
|
||||
attributes: ['user_id', 'quiz_id', 'assessment_id'],
|
||||
});
|
||||
for (const attempt of passedAttempts) {
|
||||
if (attempt.quiz_id) {
|
||||
if (!passedQuizIdsByUser.has(attempt.user_id)) passedQuizIdsByUser.set(attempt.user_id, new Set());
|
||||
passedQuizIdsByUser.get(attempt.user_id).add(attempt.quiz_id);
|
||||
}
|
||||
if (attempt.assessment_id) passedAssessmentUserIds.add(attempt.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-user summary
|
||||
const summaryMap = {};
|
||||
for (const row of rows) {
|
||||
if (!summaryMap[row.user_id]) {
|
||||
summaryMap[row.user_id] = {
|
||||
user_id: row.user_id,
|
||||
course_status: null,
|
||||
last_accessed_at: null,
|
||||
lessons_completed: 0,
|
||||
units_completed: 0,
|
||||
};
|
||||
}
|
||||
const entry = summaryMap[row.user_id];
|
||||
|
||||
// Track latest access across all rows for this user
|
||||
if (!entry.last_accessed_at || new Date(row.last_accessed_at) > new Date(entry.last_accessed_at)) {
|
||||
entry.last_accessed_at = row.last_accessed_at;
|
||||
}
|
||||
|
||||
if (row.type === 'course') entry.course_status = row.status;
|
||||
if (row.type === 'unit' && row.status === 'completed') entry.units_completed++;
|
||||
if (row.type === 'lesson' && row.status === 'completed') entry.lessons_completed++;
|
||||
}
|
||||
|
||||
const result = await Promise.all(Object.values(summaryMap).map(async (entry) => {
|
||||
const u = userMap[entry.user_id];
|
||||
const avatar = await resolveAvatarUrl(u?.personal_info?.avatar);
|
||||
return {
|
||||
...entry,
|
||||
user: {
|
||||
email: u?.email ?? null,
|
||||
full_name: u?.personal_info?.name?.full_name ?? null,
|
||||
avatar_stream_token: avatar?.stream_token ?? null,
|
||||
},
|
||||
units_total,
|
||||
lessons_total,
|
||||
// Fall back to in_progress if the course row hasn't been written yet
|
||||
course_status: entry.course_status ?? 'in_progress',
|
||||
quizzes_pending: quizIds.length - (passedQuizIdsByUser.get(entry.user_id)?.size ?? 0),
|
||||
assessment_pending: !!assessment && !passedAssessmentUserIds.has(entry.user_id),
|
||||
};
|
||||
}));
|
||||
|
||||
// Sort: completed last, most recent first within each group
|
||||
result.sort((a, b) => {
|
||||
if (a.course_status !== b.course_status) {
|
||||
return a.course_status === 'completed' ? 1 : -1;
|
||||
}
|
||||
return new Date(b.last_accessed_at) - new Date(a.last_accessed_at);
|
||||
});
|
||||
|
||||
return R.success(res, 'Course reading progress retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][COURSE READING PROGRESS][LIST]', err);
|
||||
return R.error(res, 'Could not retrieve reading progress.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── DETAIL — single user's full lesson/unit breakdown ─────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// GET /admin/courses/:courseId/reading-progress/users/:userId
|
||||
// Loaded lazily when the admin expands a user row.
|
||||
// Returns units with their lessons and the progress status per item.
|
||||
|
||||
exports.getUserReadingProgress = async (req, res) => {
|
||||
try {
|
||||
const { courseId, userId } = req.params;
|
||||
|
||||
const [unitRows, progressRows] = await Promise.all([
|
||||
Unit.findAll({
|
||||
where: notDeleted,
|
||||
attributes: ['unit_id', 'uuid', 'title'],
|
||||
include: [
|
||||
{
|
||||
model: CourseUnit,
|
||||
as: 'courseLinks',
|
||||
where: { course_id: courseId },
|
||||
required: true,
|
||||
attributes: ['order_index'],
|
||||
},
|
||||
{
|
||||
model: Lesson,
|
||||
as: 'lessons',
|
||||
where: notDeleted,
|
||||
required: false,
|
||||
attributes: ['lesson_id', 'uuid', 'title'],
|
||||
through: { attributes: ['order_index'] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
CourseReadingProgress.findAll({
|
||||
where: { course_id: courseId, user_id: userId },
|
||||
attributes: ['reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'],
|
||||
}),
|
||||
]);
|
||||
|
||||
// Sort by junction order (course-level, then unit-level for lessons)
|
||||
const units = flattenUnits(unitRows.map((u) => {
|
||||
const plain = u.toJSON();
|
||||
plain.CourseUnit = { order_index: plain.courseLinks?.[0]?.order_index ?? 0 };
|
||||
delete plain.courseLinks;
|
||||
return plain;
|
||||
}));
|
||||
|
||||
// Build a quick lookup: { [reference_id (uuid)]: status }
|
||||
const progressMap = Object.fromEntries(
|
||||
progressRows.map((r) => [r.reference_id, { status: r.status, completed_at: r.completed_at }])
|
||||
);
|
||||
|
||||
const breakdown = units.map((unit) => ({
|
||||
unit_id: unit.unit_id,
|
||||
uuid: unit.uuid,
|
||||
title: unit.title,
|
||||
status: progressMap[unit.uuid]?.status ?? null,
|
||||
lessons: (unit.lessons ?? []).map((lesson) => ({
|
||||
lesson_id: lesson.lesson_id,
|
||||
uuid: lesson.uuid,
|
||||
title: lesson.title,
|
||||
status: progressMap[lesson.uuid]?.status ?? null,
|
||||
completed_at: progressMap[lesson.uuid]?.completed_at ?? null,
|
||||
})),
|
||||
}));
|
||||
|
||||
return R.success(res, 'User reading progress retrieved.', breakdown);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][COURSE READING PROGRESS][USER DETAIL]', err);
|
||||
return R.error(res, 'Could not retrieve user reading progress.', 500);
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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,
|
||||
archivedUsers,
|
||||
accTypeBreakdown,
|
||||
regTypeBreakdown,
|
||||
] = await Promise.all([
|
||||
mdl_Users.count({ paranoid: false }),
|
||||
mdl_Users.count({ where: { is_active: 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 },
|
||||
],
|
||||
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,
|
||||
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_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 },
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
key: 'top_groups',
|
||||
label: 'Top Groups by Members',
|
||||
data: memberCountBreakdown.map((r) => ({
|
||||
label: r.UserGroup?.name ?? `Group ${r.group_id}`,
|
||||
value: parseInt(r.member_count, 10),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DASHBOARD][GROUPS]', err);
|
||||
return res.status(500).json({ status: 'error', message: 'Could not fetch groups dashboard data.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
# Advertisements Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/advertisements.controller.js`
|
||||
**Base URL:** `/api/admin/advertisements`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Advertisements](#get-all-advertisements)
|
||||
- [Get Single Advertisement](#get-single-advertisement)
|
||||
- [Create Advertisement](#create-advertisement)
|
||||
- [Update Advertisement](#update-advertisement)
|
||||
- [Archive Advertisement](#archive-advertisement)
|
||||
- [Bulk Archive Advertisements](#bulk-archive-advertisements)
|
||||
- [Restore Advertisement](#restore-advertisement)
|
||||
- [Bulk Restore Advertisements](#bulk-restore-advertisements)
|
||||
- [Get Archived Advertisements](#get-archived-advertisements)
|
||||
- [Get Field Values](#get-field-values)
|
||||
|
||||
---
|
||||
|
||||
## Placement Registry
|
||||
|
||||
Every advertisement belongs to a `placement` — a page + position slug drawn from a fixed
|
||||
registry (`models/advertisements/advertisements.placements.js`). The placement determines
|
||||
the advertisement's `type` (visual format) automatically; `type` is **never** accepted from
|
||||
the client and is denormalized from the placement on every write.
|
||||
|
||||
| Placement key | Page | Position | Format |
|
||||
|---|---|---|---|
|
||||
| `dashboard.hero` | Dashboard | Hero (top of page) | `hero` |
|
||||
| `dashboard.popup` | Dashboard | Popup (on load) | `popup` |
|
||||
| `course_list.banner` | Courses | Banner (above course grid) | `banner` |
|
||||
| `course_details.banner` | Course Details | Banner (below hero) | `banner` |
|
||||
| `course_details.sidebar` | Course Details | Sidebar (beside course content) | `sidebar` |
|
||||
| `plans.banner` | Plans | Banner (above plan cards) | `banner` |
|
||||
|
||||
Adding a new placement is a one-line addition to that registry file plus wiring the
|
||||
corresponding client page to fetch/render it — nothing else needs to change.
|
||||
|
||||
---
|
||||
|
||||
## Status Derivation
|
||||
|
||||
Status is **never** trusted as stored — it is recomputed on every read and write:
|
||||
|
||||
| Condition | Derived Status |
|
||||
|-----------|----------------|
|
||||
| `deletedAt` is set | `archived` |
|
||||
| `is_active = false` | `draft` |
|
||||
| `end_date` < now | `expired` |
|
||||
| `start_date` > now | `scheduled` |
|
||||
| Otherwise | `active` |
|
||||
|
||||
`archived` is the only status that bypasses derivation (set explicitly by archive/restore).
|
||||
|
||||
---
|
||||
|
||||
## CTAs
|
||||
|
||||
Each advertisement supports up to **2 CTAs**:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "label": "Learn More", "link": "/courses", "variant": "default" },
|
||||
{ "label": "Sign Up", "link": "/register", "variant": "outline" }
|
||||
]
|
||||
```
|
||||
|
||||
- First CTA defaults to `"default"` variant; second defaults to `"outline"`.
|
||||
- An explicit valid variant from the client (`"default"` or `"outline"`) always wins.
|
||||
- Items beyond 2 are silently discarded.
|
||||
|
||||
---
|
||||
|
||||
## Get All Advertisements
|
||||
|
||||
**`GET /api/admin/advertisements`**
|
||||
|
||||
Returns a paginated list of active (non-deleted) advertisements. Status is resynced on the way out.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| page | number | No | Default: `1` |
|
||||
| limit | number | No | Default: `10`, max: `1000` |
|
||||
| filters | array | No | JSON array of filter objects |
|
||||
| sort | array | No | JSON array of sort objects |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Advertisements retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Single Advertisement
|
||||
|
||||
**`GET /api/admin/advertisements/:advertisementId`**
|
||||
|
||||
Returns one advertisement with its `image` asset and audit user info.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Advertisement retrieved.",
|
||||
"data": {
|
||||
"advertisement_id": 1,
|
||||
"uuid": "...",
|
||||
"placement": "dashboard.hero",
|
||||
"type": "hero",
|
||||
"status": "active",
|
||||
"badge_label": "New",
|
||||
"headline": "Welcome",
|
||||
"description": "...",
|
||||
"image_url": null,
|
||||
"image_asset_id": 12,
|
||||
"image": { "asset_id": 12, "display_name": "hero.jpg", "file_url": "...", "thumbnail_url": "..." },
|
||||
"ctas": [{ "label": "Start", "link": "/start", "variant": "default" }],
|
||||
"start_date": "2026-01-01T00:00:00.000Z",
|
||||
"end_date": null,
|
||||
"order": 0,
|
||||
"is_active": true,
|
||||
"size": null,
|
||||
"click_count": 0,
|
||||
"creator": { "user_id": 1, "full_name": "Admin User" },
|
||||
"updater": null,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `Invalid advertisement ID.` |
|
||||
| `404` | `Advertisement not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Create Advertisement
|
||||
|
||||
**`POST /api/admin/advertisements`**
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `placement` | string | **Yes** | A placement registry key, e.g. `dashboard.hero` — see [Placement Registry](#placement-registry). Determines `type` automatically. |
|
||||
| `createdBy` | number | **Yes** | User ID of creator |
|
||||
| `badge_label` | string | No | Small label shown on the ad |
|
||||
| `headline` | string | No | Main heading |
|
||||
| `description` | string | No | Body text |
|
||||
| `image_url` | string | No | Direct image URL |
|
||||
| `image_asset_id` | number | No | FK to `assets` table |
|
||||
| `ctas` | array | No | Up to 2 CTA objects `[{ label, link, variant }]` |
|
||||
| `start_date` | date | No | ISO date string |
|
||||
| `end_date` | date | No | ISO date string |
|
||||
| `order` | number | No | Display order. Default: `0` |
|
||||
| `is_active` | boolean | No | Default: `true` |
|
||||
| `size` | string | No | `sm`, `md`, `lg` — banner only |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Advertisement created.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `placement is required.` |
|
||||
| `400` | `Invalid placement. Must be one of: dashboard.hero, dashboard.popup, ...` |
|
||||
| `400` | `createdBy is required.` |
|
||||
| `400` | `Invalid size. Must be one of: sm, md, lg` |
|
||||
|
||||
---
|
||||
|
||||
## Update Advertisement
|
||||
|
||||
**`PATCH /api/admin/advertisements/:advertisementId`**
|
||||
|
||||
Partial update. Only fields present in the body are changed. Status is recomputed after all fields are applied.
|
||||
|
||||
### Request Body
|
||||
Same optional fields as Create. `type` is never accepted — it's always derived from `placement`. Accepts `updatedBy`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Advertisement updated.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archive Advertisement
|
||||
|
||||
**`DELETE /api/admin/advertisements/:advertisementId`**
|
||||
|
||||
Soft-deletes the advertisement (`deletedAt` set, `status` → `archived`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Advertisement archived." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Archive Advertisements
|
||||
|
||||
**`DELETE /api/admin/advertisements/bulk`**
|
||||
|
||||
Rate-limited. Soft-deletes multiple advertisements.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2, 3], "deletedBy": 1 }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 advertisement(s) archived.",
|
||||
"archived_ids": [1, 2, 3],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore Advertisement
|
||||
|
||||
**`PATCH /api/admin/advertisements/:advertisementId/restore`**
|
||||
|
||||
Restores a soft-deleted advertisement.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Advertisement restored.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Restore Advertisements
|
||||
|
||||
**`PATCH /api/admin/advertisements/bulk-restore`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 advertisement(s) restored.",
|
||||
"restored_ids": [1, 2],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Archived Advertisements
|
||||
|
||||
**`GET /api/admin/advertisements/archived`**
|
||||
|
||||
Returns paginated list of soft-deleted advertisements.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Archived advertisements retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Field Values
|
||||
|
||||
**`GET /api/admin/advertisements/field-values`**
|
||||
|
||||
Returns distinct values for filterable advertisement fields. Used by DataTable filter dropdowns.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Field values retrieved.",
|
||||
"data": {
|
||||
"type": ["hero", "banner", "popup", "sidebar"],
|
||||
"placement": ["dashboard.hero", "dashboard.popup", "course_list.banner"],
|
||||
"status": ["active", "draft"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,428 @@
|
||||
# Assets API
|
||||
|
||||
Base path: `/api/admin/assets`
|
||||
Controller: `controllers/admin/assets.controller.js`
|
||||
Storage: Chibisafe (CDN) + PostgreSQL via Sequelize
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Multer setup
|
||||
|
||||
The upload and update-thumbnail endpoints use `multer.fields()` — make sure your route file is configured with `memoryStorage`:
|
||||
|
||||
```js
|
||||
const multer = require("multer");
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
// Upload
|
||||
router.post("/", upload.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }]), assetsCtrl.uploadAsset);
|
||||
|
||||
// Update thumbnail
|
||||
router.patch("/:assetId/thumbnail", upload.fields([{ name: "thumbnail", maxCount: 1 }]), assetsCtrl.updateThumbnail);
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
```env
|
||||
CHIBISAFE_BASE_URL=https://cdn.yourdomain.com
|
||||
CHIBISAFE_API_KEY=your-api-key
|
||||
|
||||
CHIBISAFE_ALBUM_AVATARS=uuid
|
||||
CHIBISAFE_ALBUM_VIDEOS=uuid
|
||||
CHIBISAFE_ALBUM_DOCUMENTS=uuid
|
||||
CHIBISAFE_ALBUM_THUMBNAILS=uuid
|
||||
CHIBISAFE_ALBUM_ARCHIVED=uuid
|
||||
```
|
||||
|
||||
### Album routing
|
||||
|
||||
`owner_type` is the single source of truth for which Chibisafe album a file lands in:
|
||||
|
||||
| `owner_type` | Chibisafe album | Intended use |
|
||||
|---|---|---|
|
||||
| `avatar` | avatars | Profile pictures |
|
||||
| `video` | videos | Course / content videos |
|
||||
| `document` | documents | PDF, DOCX, PPT, TXT, etc. |
|
||||
| `thumbnail` | thumbnails | Set automatically — do not send manually |
|
||||
| `image` | *(none)* | General-purpose images |
|
||||
| anything else | *(none)* | Unclassified |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
---
|
||||
|
||||
### GET `/`
|
||||
|
||||
List all assets (paginated).
|
||||
|
||||
**Query params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `page` | optional | Page number. Default: `1` |
|
||||
| `limit` | optional | Items per page. Default: `10`, max: `1000` |
|
||||
| `filters` | optional | JSON array of filter objects passed to `buildQuery` |
|
||||
| `sort` | optional | JSON array of sort objects passed to `buildQuery` |
|
||||
|
||||
**Response `200`**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Assets retrieved.",
|
||||
"data": [...],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"totalRecords": 42,
|
||||
"totalPages": 5,
|
||||
"hasPrevPage": false,
|
||||
"hasNextPage": true
|
||||
},
|
||||
"attributes": [...]
|
||||
}
|
||||
```
|
||||
|
||||
Soft-deleted assets are excluded automatically. Hidden fields (per `adminExclude`): `checksum`, `storage_bucket`, `storage_key`, `deletedBy`.
|
||||
|
||||
---
|
||||
|
||||
### GET `/:assetId`
|
||||
|
||||
Get a single asset by primary key.
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | required | Asset primary key (BIGINT) |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` |
|
||||
| `400` | Invalid asset ID |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### POST `/`
|
||||
|
||||
Upload a new asset.
|
||||
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
#### File fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `file` | **required** | The main asset (image, video, document, etc.) |
|
||||
| `thumbnail` | **required if video** | Cover image for the video. Ignored for non-video files. |
|
||||
|
||||
#### Text fields
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `uploadedBy` | **required** | — | User ID (BIGINT) of the uploader |
|
||||
| `storage_provider` | **required** | — | `chibisafe` \| `local` \| `s3` \| `gcs` \| `cloudinary` |
|
||||
| `owner_type` | optional | `null` | Determines album routing: `avatar`, `video`, `document`, `image` |
|
||||
| `owner_id` | optional | `null` | ID of the owning entity (course ID, user ID, etc.) |
|
||||
| `display_name` | optional | original filename | Human-readable name shown in the UI |
|
||||
| `description` | optional | `null` | Free-text description |
|
||||
| `is_public` | optional | `false` | `true` \| `false` |
|
||||
| `access_level` | optional | `private` | `public` \| `private` \| `restricted` |
|
||||
| `storage_bucket` | optional | `null` | Bucket name (S3 / GCS only) |
|
||||
| `storage_key` | optional | `null` | Override storage key. Auto-set for Chibisafe (uses Chibisafe file UUID). |
|
||||
| `file_url` | conditional | — | Required when `storage_provider` is not `local` or `chibisafe` |
|
||||
| `width` | optional (non-video) | `null` | Image/document width in px. Ignored for videos. |
|
||||
| `height` | optional (non-video) | `null` | Image/document height in px. Ignored for videos. |
|
||||
|
||||
#### Auto-extracted fields (videos only — do not send)
|
||||
|
||||
These are extracted server-side via **ffprobe** and will override anything the client sends:
|
||||
|
||||
| Field | Source | Example |
|
||||
|---|---|---|
|
||||
| `width` | ffprobe | `1920` |
|
||||
| `height` | ffprobe | `1080` |
|
||||
| `resolution` | derived | `1080p`, `720p`, `4K` |
|
||||
| `duration` | ffprobe | `281.49` (seconds) |
|
||||
| `frame_rate` | ffprobe | `23.976` (fps) |
|
||||
| `bitrate` | ffprobe | `447933` (bps) |
|
||||
| `video_codec` | ffprobe | `H.264`, `H.265`, `AV1`, `VP9` |
|
||||
| `audio_codec` | ffprobe | `AAC`, `MP3`, `Opus` |
|
||||
| `thumbnail_url` | Chibisafe upload | CDN URL of the uploaded thumbnail |
|
||||
|
||||
#### Transaction strategy
|
||||
|
||||
```
|
||||
Phase 1 (no DB connection held — slow I/O):
|
||||
├─ Validate inputs
|
||||
├─ Upload main file to Chibisafe → track UUID for rollback
|
||||
├─ Run ffprobe on video buffer → extract metadata
|
||||
└─ Upload thumbnail to Chibisafe → track UUID for rollback
|
||||
|
||||
Phase 2 (transaction open ~milliseconds):
|
||||
└─ Asset.create() → commit
|
||||
|
||||
On Phase 2 failure:
|
||||
└─ rollback DB + deleteFile() all tracked Chibisafe UUIDs
|
||||
```
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `201` | `{ data: asset }` — fully populated asset record |
|
||||
| `400` | Missing `file`, `uploadedBy`, or `thumbnail` (for videos); buffer issues |
|
||||
| `500` | DB or Chibisafe error — Chibisafe uploads are cleaned up automatically |
|
||||
|
||||
#### Example — video upload (Postman)
|
||||
|
||||
```
|
||||
POST /api/admin/assets
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file → (attach .mp4)
|
||||
thumbnail → (attach .jpg)
|
||||
uploadedBy → 1
|
||||
storage_provider → chibisafe
|
||||
owner_type → video
|
||||
owner_id → 10
|
||||
display_name → Intro to React
|
||||
is_public → true
|
||||
access_level → public
|
||||
```
|
||||
|
||||
#### Example — avatar upload
|
||||
|
||||
```
|
||||
POST /api/admin/assets
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file → (attach .jpg)
|
||||
uploadedBy → 1
|
||||
storage_provider → chibisafe
|
||||
owner_type → avatar
|
||||
owner_id → 5
|
||||
```
|
||||
|
||||
#### Example — document upload
|
||||
|
||||
```
|
||||
POST /api/admin/assets
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file → (attach .pdf)
|
||||
uploadedBy → 1
|
||||
storage_provider → chibisafe
|
||||
owner_type → document
|
||||
owner_id → 7
|
||||
display_name → Module 1 Handout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH `/:assetId/thumbnail`
|
||||
|
||||
Replace the thumbnail image of an existing asset by uploading a new file.
|
||||
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key |
|
||||
|
||||
**File field**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `thumbnail` | **required** | New thumbnail image file |
|
||||
|
||||
**How it works**
|
||||
|
||||
1. Uploads the new thumbnail to Chibisafe (thumbnails album).
|
||||
2. Updates `thumbnail_url` on the asset record.
|
||||
3. Deletes the old thumbnail from Chibisafe (best-effort — non-fatal if it fails).
|
||||
|
||||
> **Note:** Old thumbnail cleanup requires a `thumbnail_storage_key` column on the Asset model to track the previous Chibisafe file UUID. Without it, the old thumbnail remains on Chibisafe but the DB record is updated correctly.
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` — updated asset with new `thumbnail_url` |
|
||||
| `400` | No thumbnail file attached |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### PUT `/:assetId`
|
||||
|
||||
Update asset metadata. **File uploads are blocked on this endpoint.**
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key |
|
||||
|
||||
**Body** — all fields optional, send only what changes
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `display_name` | string | New display name |
|
||||
| `description` | string | New description |
|
||||
| `owner_type` | string | New owner type |
|
||||
| `owner_id` | number | New owner entity ID |
|
||||
| `is_public` | boolean | `true` \| `false` |
|
||||
| `access_level` | string | `public` \| `private` \| `restricted` |
|
||||
| `thumbnail_url` | string | Manually replace thumbnail URL (use PATCH `/thumbnail` to upload a file instead) |
|
||||
| `width` | number | Width in px. Re-derives `resolution` automatically. |
|
||||
| `height` | number | Height in px. Re-derives `resolution` automatically. |
|
||||
| `duration` | number | Duration in seconds |
|
||||
| `frame_rate` | number | fps |
|
||||
| `bitrate` | number | bps |
|
||||
| `video_codec` | string | e.g. `H.264` |
|
||||
| `audio_codec` | string | e.g. `AAC` |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` |
|
||||
| `400` | Invalid ID or file attached to request |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### DELETE `/:assetId`
|
||||
|
||||
Soft-delete a single asset.
|
||||
|
||||
Sets `deletedAt` on the DB record and moves the file to the **archived** album on Chibisafe (best-effort — non-fatal if Chibisafe is unavailable).
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key |
|
||||
|
||||
**Body**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `deletedBy` | optional | User ID performing the delete |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | Asset deleted |
|
||||
| `400` | Invalid asset ID |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### DELETE `/bulk`
|
||||
|
||||
Soft-delete multiple assets in one call.
|
||||
|
||||
All matching Chibisafe files are moved to the **archived** album in a single API call.
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**Body**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `ids` | **required** | Non-empty array of asset IDs: `[1, 2, 3]` |
|
||||
| `deletedBy` | optional | User ID performing the delete |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `N asset(s) deleted` |
|
||||
| `400` | `ids` missing or empty |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### POST `/:assetId/restore`
|
||||
|
||||
Restore a soft-deleted asset.
|
||||
|
||||
Clears `deletedAt` and `deletedBy` on the DB record, then moves the file on Chibisafe from the **archived** album back to its home album based on `owner_type`:
|
||||
|
||||
| `owner_type` | Moved back to |
|
||||
|---|---|
|
||||
| `video` | videos album |
|
||||
| `avatar` | avatars album |
|
||||
| `document` | documents album |
|
||||
| `image` / anything else | no move (no dedicated album) |
|
||||
|
||||
The Chibisafe move is best-effort — a failed move will not block or roll back the DB restore.
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key (must be soft-deleted) |
|
||||
|
||||
**Body:** none required.
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` — Asset restored |
|
||||
| `404` | Asset not found or not deleted |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
## Response shape
|
||||
|
||||
All responses use `R.success` / `R.error` from `response.util`:
|
||||
|
||||
```json
|
||||
// success
|
||||
{
|
||||
"message": "Asset uploaded.",
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
// error
|
||||
{
|
||||
"message": "Asset not found.",
|
||||
"status": 404
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `models/assets/assets.mdl.js` | Sequelize model |
|
||||
| `models/assets/assets.attributes.js` | Exclude sets, paginate config |
|
||||
| `services/chibisafe.service.js` | Chibisafe API wrapper (upload, delete, archive, album) |
|
||||
| `services/ffprobe.service.js` | ffprobe metadata extraction for videos |
|
||||
| `utils/paginate.util.js` | Paginated `findAndCountAll` used by `getAssets` |
|
||||
| `utils/response.util.js` | `R.success` / `R.error` response helpers |
|
||||
@@ -0,0 +1,171 @@
|
||||
# Categories Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/categories.controller.js`
|
||||
**Base URL:** `/api/admin/categories`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Categories](#get-all-categories)
|
||||
- [Get Single Category](#get-single-category)
|
||||
- [Create Category](#create-category)
|
||||
- [Update Category](#update-category)
|
||||
- [Archive Category](#archive-category)
|
||||
- [Restore Category](#restore-category)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `slug` is auto-generated from `name` on create and update: lowercased, trimmed, non-alphanumeric runs replaced with `-`.
|
||||
- `slug` and `name` must be **unique** across all categories (including archived ones).
|
||||
- `paranoid: false` is used on GET All and GET One, so archived categories are visible.
|
||||
|
||||
---
|
||||
|
||||
## Get All Categories
|
||||
|
||||
**`GET /api/admin/categories`**
|
||||
|
||||
Returns all categories ordered alphabetically by name. Includes archived rows.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Categories retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Business",
|
||||
"slug": "business",
|
||||
"description": "Business courses",
|
||||
"is_active": true,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Single Category
|
||||
|
||||
**`GET /api/admin/categories/:id`**
|
||||
|
||||
Returns one category. Includes archived.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Category retrieved.",
|
||||
"data": { "id": 1, "name": "Business", "slug": "business", ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Category not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Create Category
|
||||
|
||||
**`POST /api/admin/categories`**
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Display name. Must be unique. |
|
||||
| `description` | string | No | Optional description. |
|
||||
| `is_active` | boolean | No | Default: `true` |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Category created.",
|
||||
"data": { "id": 2, "name": "Design", "slug": "design", "is_active": true, ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `name is required.` |
|
||||
| `409` | `A category with that name already exists.` |
|
||||
|
||||
---
|
||||
|
||||
## Update Category
|
||||
|
||||
**`PUT /api/admin/categories/:id`**
|
||||
|
||||
Full update. Only non-`null`/`undefined` fields are changed. `slug` is re-generated if `name` changes.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | No | New display name. |
|
||||
| `description` | string | No | |
|
||||
| `is_active` | boolean | No | |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Category updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Category not found.` |
|
||||
| `409` | `A category with that name already exists.` |
|
||||
|
||||
---
|
||||
|
||||
## Archive Category
|
||||
|
||||
**`DELETE /api/admin/categories/:id`**
|
||||
|
||||
Soft-deletes the category (`deletedAt` set).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Category archived." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Category not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Restore Category
|
||||
|
||||
**`POST /api/admin/categories/:id/restore`**
|
||||
|
||||
Restores a soft-deleted category.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Category restored.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Category not found.` |
|
||||
@@ -0,0 +1,843 @@
|
||||
# Courses Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/courses.controller.js`
|
||||
**Base URL:** `/api/admin/courses`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Courses](#courses)
|
||||
- [Get All Courses](#get-all-courses)
|
||||
- [Get Single Course](#get-single-course)
|
||||
- [Create Course](#create-course)
|
||||
- [Update Course](#update-course)
|
||||
- [Archive Course](#archive-course)
|
||||
- [Bulk Archive Courses](#bulk-archive-courses)
|
||||
- [Restore Course](#restore-course)
|
||||
- [Bulk Restore Courses](#bulk-restore-courses)
|
||||
- [Get Archived Courses](#get-archived-courses)
|
||||
- [Get Archived Course](#get-archived-course)
|
||||
- [Flat Lists (dropdowns)](#flat-lists)
|
||||
- [Get Course Field Values](#get-course-field-values)
|
||||
- [Course Instructors](#course-instructors)
|
||||
- [Get Instructors](#get-instructors)
|
||||
- [Sync Instructors](#sync-instructors)
|
||||
- [Course Prerequisites](#course-prerequisites)
|
||||
- [Get Prerequisites](#get-prerequisites)
|
||||
- [Sync Prerequisites](#sync-prerequisites)
|
||||
- [Course Assessment](#course-assessment)
|
||||
- [Get Assessment](#get-assessment)
|
||||
- [Create Assessment](#create-assessment)
|
||||
- [Update Assessment](#update-assessment)
|
||||
- [Archive/Restore Assessment](#archiverestore-assessment)
|
||||
- [Quiz Questions](#quiz-questions)
|
||||
- [Get Questions](#get-questions)
|
||||
- [Create Question](#create-question)
|
||||
- [Update Question](#update-question)
|
||||
- [Archive/Restore Question](#archiverestore-question)
|
||||
- [Bulk Archive/Restore Questions](#bulk-archiverestore-questions)
|
||||
- [Units](#units)
|
||||
- [Get All Units](#get-all-units)
|
||||
- [Get Single Unit](#get-single-unit)
|
||||
- [Create Unit](#create-unit)
|
||||
- [Update Unit](#update-unit)
|
||||
- [Archive/Restore Unit](#archiverestore-unit)
|
||||
- [Bulk Archive/Restore Units](#bulk-archiverestore-units)
|
||||
- [Get Unit Field Values](#get-unit-field-values)
|
||||
- [Unit Quiz](#unit-quiz)
|
||||
- [Get Quiz](#get-quiz)
|
||||
- [Create Quiz](#create-quiz)
|
||||
- [Update Quiz](#update-quiz)
|
||||
- [Archive/Restore Quiz](#archiverestore-quiz)
|
||||
- [Lessons](#lessons)
|
||||
- [Get All Lessons](#get-all-lessons)
|
||||
- [Get Single Lesson](#get-single-lesson)
|
||||
- [Create Lesson](#create-lesson)
|
||||
- [Update Lesson](#update-lesson)
|
||||
- [Archive/Restore Lesson](#archiverestore-lesson)
|
||||
- [Bulk Archive/Restore Lessons](#bulk-archiverestore-lessons)
|
||||
- [Get Lesson Field Values](#get-lesson-field-values)
|
||||
- [Lesson Page](#lesson-page)
|
||||
- [Get Lesson Page](#get-lesson-page)
|
||||
- [Upsert Lesson Page](#upsert-lesson-page)
|
||||
- [Course Reading Progress](#course-reading-progress)
|
||||
- [Get Course Reading Progress](#get-course-reading-progress)
|
||||
- [Get User Reading Progress](#get-user-reading-progress)
|
||||
|
||||
---
|
||||
|
||||
## Course Hierarchy
|
||||
|
||||
```
|
||||
Course
|
||||
├── CourseObjective[]
|
||||
├── CoursePrerequisite[]
|
||||
├── CourseAssessment (one)
|
||||
│ └── QuizQuestion[] → QuizOption[]
|
||||
├── CourseInstructor[]
|
||||
└── Unit[]
|
||||
├── UnitQuiz (one)
|
||||
│ └── QuizQuestion[] → QuizOption[]
|
||||
└── Lesson[]
|
||||
├── LessonObjective[]
|
||||
└── LessonPage (one) { blocks: [] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Courses
|
||||
|
||||
### Get All Courses
|
||||
|
||||
**`GET /api/admin/courses`**
|
||||
|
||||
Returns paginated active courses.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
| `filters` | array | No | JSON filter array |
|
||||
| `sort` | array | No | JSON sort array |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Courses retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 12, "totalPages": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Course
|
||||
|
||||
**`GET /api/admin/courses/:courseId`**
|
||||
|
||||
Returns the full course tree: units (with lessons and quiz), objectives, prerequisites, and assessment.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course retrieved.",
|
||||
"data": {
|
||||
"course_id": 1,
|
||||
"uuid": "...",
|
||||
"title": "Advanced JavaScript",
|
||||
"description": "...",
|
||||
"course_code": "JS-201",
|
||||
"order_index": 0,
|
||||
"level": "advanced",
|
||||
"subscription": "premium",
|
||||
"duration_seconds": 7200,
|
||||
"objectives": [{ "objective_id": 1, "text": "Understand closures", "order_index": 0 }],
|
||||
"prerequisites": [],
|
||||
"assessment": { ... },
|
||||
"units": [
|
||||
{
|
||||
"unit_id": 1, "title": "Closures", "order_index": 0,
|
||||
"lessons": [{ "lesson_id": 1, "title": "What is a closure?", "order_index": 0 }],
|
||||
"quiz": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Course
|
||||
|
||||
**`POST /api/admin/courses`**
|
||||
|
||||
Creates a course with optional objectives and category assignments in a single transaction.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | **Yes** | Course title |
|
||||
| `description` | string | No | |
|
||||
| `course_code` | string | No | Unique course identifier |
|
||||
| `order_index` | number | No | Default: `0` |
|
||||
| `level` | string | No | `beginner`, `intermediate`, `advanced` |
|
||||
| `subscription` | string | No | `free`, `premium`. Default: `free` |
|
||||
| `objectives` | array | No | `[{ text, order_index }]` |
|
||||
| `category_ids` | array | No | Category IDs to assign |
|
||||
| `createdBy` | number | No | Creator user ID |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course created.",
|
||||
"data": { "course_id": 5, "title": "Advanced JavaScript", ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Course
|
||||
|
||||
**`PUT /api/admin/courses/:courseId`**
|
||||
|
||||
Updates course fields. When `objectives` or `category_ids` are provided, they replace the existing sets.
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `description`, `order_index`, `course_code`, `level`, `subscription`, `objectives`, `category_ids`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive Course
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId`**
|
||||
|
||||
Soft-deletes the course.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Course archived." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Courses
|
||||
|
||||
**`DELETE /api/admin/courses/bulk`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2, 3] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Restore Course
|
||||
|
||||
**`PATCH /api/admin/courses/:courseId/restore`**
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Courses
|
||||
|
||||
**`PATCH /api/admin/courses/restore/bulk`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Courses
|
||||
|
||||
**`GET /api/admin/courses/archives`**
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Course
|
||||
|
||||
**`GET /api/admin/courses/archives/:courseId`**
|
||||
|
||||
---
|
||||
|
||||
### Flat Lists
|
||||
|
||||
Lightweight endpoints that return `uuid + title` arrays (no pagination). Used by the task requirement builder dropdowns.
|
||||
|
||||
**`GET /api/admin/courses/flat`** — all active courses: `[{ uuid, title }]`
|
||||
|
||||
**`GET /api/admin/courses/units-flat`** — all active units: `[{ uuid, title, order_index, course_title }]`
|
||||
|
||||
**`GET /api/admin/courses/lessons-flat`** — all active lessons: `[{ uuid, title, order_index, unit_title, unit_order, course_title }]`
|
||||
|
||||
---
|
||||
|
||||
### Get Course Field Values
|
||||
|
||||
**`GET /api/admin/courses/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Course Instructors
|
||||
|
||||
### Get Instructors
|
||||
|
||||
**`GET /api/admin/courses/:courseId/instructors`**
|
||||
|
||||
Returns instructors ordered by `order_index`. Includes linked `users` (staff/admin accounts) when `user_id` is set.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Instructors retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"course_id": 5,
|
||||
"user_id": 3,
|
||||
"display_name": "Dr. Jane Smith",
|
||||
"order_index": 0,
|
||||
"user": { "user_id": 3, "email": "jane@example.com", "acc_type": "staff", "personal_info": { ... } }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync Instructors
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/instructors`**
|
||||
|
||||
Replaces the full instructor list for a course in a single transaction.
|
||||
|
||||
- Validates any `user_id` values — they must be `staff` or `admin` accounts.
|
||||
- Passing an empty array clears all instructors.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"instructors": [
|
||||
{ "user_id": 3, "display_name": "Dr. Jane Smith", "order_index": 0 },
|
||||
{ "user_id": null, "display_name": "External Contributor", "order_index": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Course Prerequisites
|
||||
|
||||
### Get Prerequisites
|
||||
|
||||
**`GET /api/admin/courses/:courseId/prerequisites`**
|
||||
|
||||
Returns prerequisites ordered by `order_index`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Prerequisites retrieved.",
|
||||
"data": [
|
||||
{ "prereq_id": 1, "course_id": 5, "ref_type": "course", "ref_id": 2, "order_index": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync Prerequisites
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/prerequisites`**
|
||||
|
||||
Replaces the full prerequisite list. Valid `ref_type` values: `course`, `unit`, `lesson`.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"prerequisites": [
|
||||
{ "ref_type": "course", "ref_id": 2 },
|
||||
{ "ref_type": "unit", "ref_id": 7 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Course Assessment
|
||||
|
||||
One assessment per course. Assessment questions are shared with unit quizzes via polymorphic `assessment_id` / `quiz_id` fields.
|
||||
|
||||
### Get Assessment
|
||||
|
||||
**`GET /api/admin/courses/:courseId/assessment`**
|
||||
|
||||
Returns the assessment with its questions and options.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Assessment retrieved.",
|
||||
"data": {
|
||||
"assessment_id": 1,
|
||||
"uuid": "...",
|
||||
"course_id": 5,
|
||||
"title": "Final Exam",
|
||||
"is_required": true,
|
||||
"passing_score": 80,
|
||||
"time_limit_minutes": 60,
|
||||
"max_questions": 20,
|
||||
"questions": [
|
||||
{
|
||||
"question_id": 1, "type": "multiple_choice", "question": "What is a closure?",
|
||||
"explanation": "...", "points": 2, "order_index": 0,
|
||||
"options": [
|
||||
{ "option_id": 1, "text": "A function + its outer scope", "is_correct": true, "order_index": 0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Assessment
|
||||
|
||||
**`POST /api/admin/courses/:courseId/assessment`**
|
||||
|
||||
Only one assessment per course. Returns `409` if one already exists.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | No | |
|
||||
| `is_required` | boolean | No | Default: `false` |
|
||||
| `passing_score` | number | No | Default: `70` |
|
||||
| `time_limit_minutes` | number | No | `null` = no limit |
|
||||
| `max_questions` | number | No | `null` = show all |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Assessment
|
||||
|
||||
**`PATCH /api/admin/courses/:courseId/assessment/:assessmentId`**
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Assessment
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/assessment/:assessmentId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/assessment/:assessmentId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/assessment/archives`** — get archived assessment
|
||||
|
||||
---
|
||||
|
||||
## Quiz Questions
|
||||
|
||||
Shared by both **Unit Quizzes** and **Course Assessments**. The parent is determined by the route:
|
||||
|
||||
- Under a unit quiz: `/:courseId/units/:unitId/quiz/:quizId/questions`
|
||||
- Under an assessment: `/:courseId/assessment/:assessmentId/questions`
|
||||
|
||||
### Question Types
|
||||
|
||||
| Type | Options Required |
|
||||
|------|----------------|
|
||||
| `true_false` | Auto-generated `[True, False]` if `options` is empty |
|
||||
| `multiple_choice` | Exactly one `is_correct: true` option |
|
||||
| `multi_select` | One or more `is_correct: true` options |
|
||||
|
||||
### Get Questions
|
||||
|
||||
**`GET .../:parentId/questions`**
|
||||
|
||||
Returns questions with options, ordered by `order_index`.
|
||||
|
||||
---
|
||||
|
||||
### Create Question
|
||||
|
||||
**`POST .../:parentId/questions`**
|
||||
|
||||
Creates a question and its options in a transaction. `true_false` questions auto-generate `[True, False]` options if `options` is omitted.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `type` | string | **Yes** | `true_false`, `multiple_choice`, `multi_select` |
|
||||
| `question` | string | **Yes** | Question text |
|
||||
| `explanation` | string | No | Shown after answer |
|
||||
| `order_index` | number | No | Default: `0` |
|
||||
| `points` | number | No | Default: `1` |
|
||||
| `options` | array | No | `[{ text, is_correct, order_index }]` |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Question created.",
|
||||
"data": { "question_id": 1, "type": "multiple_choice", "options": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Question
|
||||
|
||||
**`PATCH .../:parentId/questions/:questionId`**
|
||||
|
||||
When `options` is provided, the full option set is replaced (destroy + re-insert).
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Question
|
||||
|
||||
**`DELETE .../:parentId/questions/:questionId`** — archive
|
||||
**`PATCH .../:parentId/questions/:questionId/restore`** — restore
|
||||
**`GET .../:parentId/questions/archives/:questionId`** — get archived question
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive/Restore Questions
|
||||
|
||||
**`DELETE .../:parentId/questions/bulk`** — bulk archive
|
||||
**`PATCH .../:parentId/questions/restore/bulk`** — bulk restore
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2, 3], "deletedBy": 1 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Units
|
||||
|
||||
### Get All Units
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units`**
|
||||
|
||||
Returns paginated active units for a course, ordered by `order_index`.
|
||||
|
||||
---
|
||||
|
||||
### Get Single Unit
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId`**
|
||||
|
||||
Returns a unit with its lessons and quiz.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Unit retrieved.",
|
||||
"data": {
|
||||
"unit_id": 1, "uuid": "...", "course_id": 5,
|
||||
"title": "Introduction", "description": "...",
|
||||
"order_index": 0, "duration_seconds": 1800,
|
||||
"lessons": [...],
|
||||
"quiz": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Unit
|
||||
|
||||
**`POST /api/admin/courses/:courseId/units`**
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | **Yes** | |
|
||||
| `description` | string | No | |
|
||||
| `order` | number | No | Default: `0` |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Unit
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/units/:unitId`**
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `description`, `order`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Unit
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/units/archives`** — list archived
|
||||
**`GET /api/admin/courses/:courseId/units/archives/:unitId`** — get one archived
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive/Restore Units
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/bulk`** — bulk archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/restore/bulk`** — bulk restore
|
||||
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Unit Field Values
|
||||
|
||||
**`GET /api/admin/courses/:courseId/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Unit Quiz
|
||||
|
||||
One quiz per unit. Shares `QuizQuestion` / `QuizOption` with course assessments (via `quiz_id` FK).
|
||||
|
||||
### Get Quiz
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/quiz`**
|
||||
|
||||
Returns the quiz with questions and options.
|
||||
|
||||
---
|
||||
|
||||
### Create Quiz
|
||||
|
||||
**`POST /api/admin/courses/:courseId/units/:unitId/quiz`**
|
||||
|
||||
Only one quiz per unit. Returns `409` if one already exists.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | No | |
|
||||
| `is_required` | boolean | No | Default: `false` |
|
||||
| `passing_score` | number | No | Default: `70` |
|
||||
| `max_questions` | number | No | `null` = show all |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Quiz
|
||||
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId`**
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `is_required`, `passing_score`, `max_questions`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Quiz
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId/quiz/:quizId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/quiz/archives`** — get archived quiz
|
||||
|
||||
---
|
||||
|
||||
## Lessons
|
||||
|
||||
### Get All Lessons
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons`**
|
||||
|
||||
Returns paginated active lessons, ordered by `order_index`.
|
||||
|
||||
---
|
||||
|
||||
### Get Single Lesson
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`**
|
||||
|
||||
Returns a lesson with its page (blocks) and objectives.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Lesson retrieved.",
|
||||
"data": {
|
||||
"lesson_id": 1, "uuid": "...", "unit_id": 1,
|
||||
"title": "What is a closure?", "description": "...",
|
||||
"duration_seconds": 900, "order_index": 0,
|
||||
"page": { "page_id": 1, "lesson_id": 1, "blocks": [...] },
|
||||
"objectives": [{ "objective_id": 1, "text": "Understand closures", "order_index": 0 }],
|
||||
"unit": { "unit_id": 1, "course_id": 5, ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Lesson
|
||||
|
||||
**`POST /api/admin/courses/:courseId/units/:unitId/lessons`**
|
||||
|
||||
Creates a lesson, an empty `LessonPage` (blocks: `[]`), and optional objectives in a single transaction.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | **Yes** | |
|
||||
| `description` | string | No | |
|
||||
| `order` | number | No | Default: `0` |
|
||||
| `objectives` | array | No | `[{ text, order_index }]` |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Lesson
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`**
|
||||
|
||||
When `objectives` is provided, the full set is replaced.
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `description`, `order`, `objectives`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Lesson
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/archives`** — list archived
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/archives/:lessonId`** — get one archived
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive/Restore Lessons
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId/lessons/bulk`** — bulk archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/lessons/restore/bulk`** — bulk restore
|
||||
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Lesson Field Values
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Lesson Page
|
||||
|
||||
Each lesson has exactly **one** page. A page is created automatically when a lesson is created (with empty `blocks`).
|
||||
|
||||
### Get Lesson Page
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page`**
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Lesson page retrieved.",
|
||||
"data": {
|
||||
"page_id": 1,
|
||||
"lesson_id": 3,
|
||||
"blocks": [
|
||||
{ "type": "text", "content": "A closure is..." },
|
||||
{ "type": "video", "asset_id": 12, "duration_seconds": 300 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Upsert Lesson Page
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page`**
|
||||
|
||||
Creates or replaces the lesson page's block content. After saving, `duration_seconds` on the lesson (and its ancestor unit and course) is automatically recomputed from video blocks.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `blocks` | array | **Yes** | Block array |
|
||||
| `updatedBy` | number | No | |
|
||||
|
||||
### Response `200` (updated) / `201` (created)
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Lesson page updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `blocks must be an array.` |
|
||||
| `404` | `Lesson not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Course Reading Progress
|
||||
|
||||
> These endpoints are served from `controllers/admin/course_reading_progress.controller.js` but mounted on the `/api/admin/courses` router.
|
||||
|
||||
### Get Course Reading Progress
|
||||
|
||||
**`GET /api/admin/courses/:courseId/reading-progress`**
|
||||
|
||||
Returns one summary row per user who has touched the course. Includes lesson/unit completion counts derived from the live course structure (not from stored counters).
|
||||
|
||||
Sorted: in-progress users first (most recent access first), completed users last.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course reading progress retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"user_id": 42,
|
||||
"course_status": "in_progress",
|
||||
"last_accessed_at": "2026-06-21T09:00:00.000Z",
|
||||
"lessons_completed": 3,
|
||||
"units_completed": 1,
|
||||
"user": {
|
||||
"email": "user@example.com",
|
||||
"full_name": "Jane Doe",
|
||||
"avatar_url": "https://..."
|
||||
},
|
||||
"units_total": 4,
|
||||
"lessons_total": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get User Reading Progress
|
||||
|
||||
**`GET /api/admin/courses/:courseId/reading-progress/users/:userId`**
|
||||
|
||||
Loaded lazily when the admin expands a user row. Returns the full unit → lesson breakdown with progress status per item.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User reading progress retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"unit_id": 1, "uuid": "...", "title": "Introduction",
|
||||
"status": "completed",
|
||||
"lessons": [
|
||||
{
|
||||
"lesson_id": 1, "uuid": "...", "title": "What is a closure?",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-06-20T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"lesson_id": 2, "uuid": "...", "title": "Closure examples",
|
||||
"status": null,
|
||||
"completed_at": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
# Dashboard Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/dashboard.controller.js`
|
||||
**Base URL:** `/api/admin/dashboard`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Users Dashboard](#users-dashboard)
|
||||
- [Groups Dashboard](#groups-dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Users Dashboard
|
||||
|
||||
**`GET /api/admin/dashboard/users`**
|
||||
|
||||
Returns summary stats and breakdowns for all users. All counts run in parallel.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users dashboard data fetched.",
|
||||
"data": {
|
||||
"stats": [
|
||||
{ "key": "total", "label": "Total Users", "value": 100 },
|
||||
{ "key": "active", "label": "Active Users", "value": 85 },
|
||||
{ "key": "inactive", "label": "Inactive Users", "value": 10 },
|
||||
{ "key": "verified", "label": "Verified", "value": 80 },
|
||||
{ "key": "archived", "label": "Archived", "value": 5 }
|
||||
],
|
||||
"breakdowns": [
|
||||
{
|
||||
"key": "acc_type",
|
||||
"label": "By Account Type",
|
||||
"data": [
|
||||
{ "label": "user", "value": 90 },
|
||||
{ "label": "staff", "value": 8 },
|
||||
{ "label": "admin", "value": 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "reg_type",
|
||||
"label": "By Registration Type",
|
||||
"data": [
|
||||
{ "label": "system", "value": 75 },
|
||||
{ "label": "google", "value": 25 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Groups Dashboard
|
||||
|
||||
**`GET /api/admin/dashboard/groups`**
|
||||
|
||||
Returns summary stats and a top-10 groups-by-member-count breakdown.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Groups dashboard data fetched.",
|
||||
"data": {
|
||||
"stats": [
|
||||
{ "key": "total", "label": "Total Groups", "value": 20 },
|
||||
{ "key": "active", "label": "Active Groups", "value": 18 },
|
||||
{ "key": "inactive", "label": "Inactive Groups", "value": 0 },
|
||||
{ "key": "archived", "label": "Archived", "value": 2 },
|
||||
{ "key": "empty", "label": "Empty Groups", "value": 3 }
|
||||
],
|
||||
"breakdowns": [
|
||||
{
|
||||
"key": "top_groups",
|
||||
"label": "Top Groups by Members",
|
||||
"data": [
|
||||
{ "label": "Engineering", "value": 42 },
|
||||
{ "label": "Marketing", "value": 30 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,188 @@
|
||||
# Landing Pages / Page Builder — Schema Documentation
|
||||
|
||||
**Migrations:** `41b`, `42`, `43`, `44`, `45`, `49`
|
||||
**Base URL (planned):** `/api/admin/pages`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
> **TODO:** Controller, service, and routes for this module have not been created yet.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Overview](#overview)
|
||||
- [Schema](#schema)
|
||||
- [pages](#pages)
|
||||
- [landing_pages](#landing_pages)
|
||||
- [page_sections](#page_sections)
|
||||
- [page_blocks](#page_blocks)
|
||||
- [page_templates](#page_templates)
|
||||
- [page_user_groups](#page_user_groups)
|
||||
- [Relationships](#relationships)
|
||||
- [Design Decisions](#design-decisions)
|
||||
- [ENUM Types](#enum-types)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The page builder uses a generalized `pages` root table as the single owner of all `page_sections`. This means sections and blocks are not locked to `landing_pages` — any future page type (`lesson_page`, `course_page`, etc.) can reuse the same section/block system by linking to `pages`.
|
||||
|
||||
**Hierarchy:**
|
||||
```
|
||||
pages
|
||||
├── landing_pages (1:1 via page_id)
|
||||
└── page_sections (1:N via page_id)
|
||||
└── page_blocks (1:N via page_section_id)
|
||||
|
||||
page_templates — standalone reusable layout snapshots
|
||||
page_user_groups — controls which groups can see a page
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
### `pages`
|
||||
|
||||
Root identity table. Every page type gets a row here first.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------|------------|----------------------|------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| type | page_type | NOT NULL | Discriminator for the page type |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_pages_type` on `(type)`
|
||||
|
||||
---
|
||||
|
||||
### `landing_pages`
|
||||
|
||||
Landing-page-specific metadata. One-to-one with `pages`.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------------|--------------------|------------------------------------|------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| page_id | BIGINT | NOT NULL, UNIQUE, FK → pages(id) | Link to root pages table |
|
||||
| title | VARCHAR(255) | NOT NULL | |
|
||||
| slug | VARCHAR(255) | NOT NULL, UNIQUE | URL path segment |
|
||||
| meta_title | VARCHAR(255) | | SEO title override |
|
||||
| meta_description | TEXT | | SEO description |
|
||||
| status | landing_page_status | NOT NULL DEFAULT 'draft' | |
|
||||
| published_at | TIMESTAMPTZ | | Set when first published |
|
||||
| created_by | BIGINT | FK → users(user_id) SET NULL | Admin who created the page |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_landing_pages_status` on `(status)`
|
||||
|
||||
---
|
||||
|
||||
### `page_sections`
|
||||
|
||||
Ordered sections within any page. References `pages`, not `landing_pages`.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------|------------------|--------------------------------|------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| page_id | BIGINT | NOT NULL, FK → pages(id) | Belongs to a page (any type) |
|
||||
| type | page_section_type | NOT NULL | Section layout type |
|
||||
| label | VARCHAR(255) | | Admin-facing label |
|
||||
| position | INT | NOT NULL DEFAULT 0 | Display order |
|
||||
| settings | JSONB | | Background, padding, layout, etc. |
|
||||
| is_visible | BOOLEAN | NOT NULL DEFAULT true | Toggle section visibility |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_page_sections_page_id` on `(page_id)`, `idx_page_sections_order` on `(page_id, position)`
|
||||
|
||||
---
|
||||
|
||||
### `page_blocks`
|
||||
|
||||
Content blocks within a section.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|-----------------|----------------|------------------------------------|----------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| page_section_id | BIGINT | NOT NULL, FK → page_sections(id) | |
|
||||
| type | page_block_type | NOT NULL | Block content type |
|
||||
| content | JSONB | | Payload — varies by type (see below) |
|
||||
| position | INT | NOT NULL DEFAULT 0 | Display order within section |
|
||||
| settings | JSONB | | Block-level style overrides |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_page_blocks_section_id` on `(page_section_id)`, `idx_page_blocks_order` on `(page_section_id, position)`
|
||||
|
||||
**`content` shape by block type:**
|
||||
| type | content fields |
|
||||
|--------|-----------------------------------------|
|
||||
| text | `{ body: string }` |
|
||||
| image | `{ src: string, alt: string }` |
|
||||
| button | `{ label: string, href: string, variant: string }` |
|
||||
| video | `{ src: string, autoplay: boolean }` |
|
||||
| form | `{ form_id: number }` |
|
||||
| spacer | `{ height: number }` |
|
||||
|
||||
---
|
||||
|
||||
### `page_templates`
|
||||
|
||||
Reusable layout snapshots. Stored as a full JSONB dump of sections + blocks — not live FK references.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---------------|-------------|------------------------|--------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| name | VARCHAR(255) | NOT NULL | Template display name |
|
||||
| thumbnail_url | TEXT | | Preview image URL |
|
||||
| structure | JSONB | | Full snapshot of sections and blocks |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
---
|
||||
|
||||
### `page_user_groups`
|
||||
|
||||
Junction table — controls which user groups can access a page.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------|-------------|--------------------------------------|-------------|
|
||||
| page_id | BIGINT | PK, FK → pages(id) ON DELETE CASCADE | |
|
||||
| group_id | BIGINT | PK, FK → user_groups(group_id) | |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_page_user_groups_group_id` on `(group_id)`
|
||||
**Primary Key:** composite `(page_id, group_id)`
|
||||
|
||||
---
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
pages 1 ──── 1 landing_pages
|
||||
pages 1 ──── N page_sections
|
||||
pages N ──── N user_groups (via page_user_groups)
|
||||
page_sections 1 ──── N page_blocks
|
||||
```
|
||||
|
||||
Cascade deletes flow top-down: deleting a `pages` row removes its `landing_pages` record, all its `page_sections`, and all nested `page_blocks` automatically.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **`pages` as root** — sections belong to `pages`, not directly to `landing_pages`. Adding a new page type (e.g. `course_page`) only requires a new detail table + adding its value to the `page_type` ENUM. No changes to `page_sections` or `page_blocks`.
|
||||
- **`page_templates.structure` is a snapshot** — templates store a JSONB copy of sections/blocks, not live FK references. This keeps templates stable when source pages are edited.
|
||||
- **`page_sections.page_id`** points to `pages(id)` directly, giving sections access to any page type without schema changes.
|
||||
|
||||
---
|
||||
|
||||
## ENUM Types
|
||||
|
||||
| Type | Values |
|
||||
|---------------------|------------------------------------------------------------------------|
|
||||
| `page_type` | `landing_page`, `lesson_page`, `course_page` |
|
||||
| `landing_page_status` | `draft`, `published`, `archived` |
|
||||
| `page_section_type` | `hero`, `features`, `cta`, `testimonials`, `faq`, `pricing`, `gallery`, `custom` |
|
||||
| `page_block_type` | `text`, `image`, `button`, `video`, `form`, `spacer` |
|
||||
@@ -0,0 +1,125 @@
|
||||
# Notifications Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/notification.controller.js`
|
||||
**Base URL:** `/api/admin/notifications`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Notifications](#get-all-notifications)
|
||||
- [Get Unseen Count](#get-unseen-count)
|
||||
- [Mark One as Seen](#mark-one-as-seen)
|
||||
- [Mark All as Seen](#mark-all-as-seen)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `admin_notifications` are **system-wide** — not scoped to a user. All admins see the same feed.
|
||||
- Records are never deleted — seen/unseen state is toggled only.
|
||||
- Notifications are created internally (e.g., task overdue events) — no POST endpoint is exposed.
|
||||
|
||||
---
|
||||
|
||||
## Get All Notifications
|
||||
|
||||
**`GET /api/admin/notifications`**
|
||||
|
||||
Returns paginated notifications, newest first.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `20`, max: `50` |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Notifications fetched.",
|
||||
"data": {
|
||||
"notifications": [
|
||||
{
|
||||
"notification_id": 1,
|
||||
"type": "task_overdue",
|
||||
"title": "Tasks Overdue",
|
||||
"message": "5 tasks are now overdue.",
|
||||
"data": { "count": 5 },
|
||||
"seen": false,
|
||||
"seen_at": null,
|
||||
"createdAt": "2026-06-19T10:00:00.000Z",
|
||||
"updatedAt": "2026-06-19T10:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 10,
|
||||
"pages": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Unseen Count
|
||||
|
||||
**`GET /api/admin/notifications/unseen`**
|
||||
|
||||
Returns a count of unseen notifications. Used for the bell badge.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Unseen count fetched.",
|
||||
"data": { "count": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mark One as Seen
|
||||
|
||||
**`PATCH /api/admin/notifications/:id/seen`**
|
||||
|
||||
Marks a single notification as seen and sets `seen_at` to the current timestamp.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Notification marked as seen.",
|
||||
"data": {
|
||||
"notification_id": 1,
|
||||
"seen": true,
|
||||
"seen_at": "2026-06-21T10:00:00.000Z",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Notification not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Mark All as Seen
|
||||
|
||||
**`PATCH /api/admin/notifications/seen-all`**
|
||||
|
||||
Marks all unseen notifications as seen in a single update.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "7 notification(s) marked as seen.",
|
||||
"data": { "count": 7 }
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Products Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/products.controller.js`
|
||||
**Base URL:** `/api/admin/products`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get Course Product](#get-course-product)
|
||||
- [Upsert Course Product](#upsert-course-product)
|
||||
- [Remove Course Product](#remove-course-product)
|
||||
- [Get Course Categories](#get-course-categories)
|
||||
- [Sync Course Categories](#sync-course-categories)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Each course has **at most one** product listing (`products` table, unique on `course_id`).
|
||||
- Upsert restores a soft-deleted product if one exists rather than creating a duplicate.
|
||||
- Categories are managed via the `course_product_categories` junction table (many-to-many between `courses` and `categories`).
|
||||
- Syncing categories replaces the full set — it is a replace-all, not an append.
|
||||
|
||||
---
|
||||
|
||||
## Get Course Product
|
||||
|
||||
**`GET /api/admin/products/courses/:courseId/product`**
|
||||
|
||||
Returns the product listing for a course, or `null` if none exists. Includes archived products (`paranoid: false`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Product retrieved.",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"course_id": 5,
|
||||
"name": "Advanced JavaScript",
|
||||
"description": "Full course access",
|
||||
"price": "49.99",
|
||||
"currency": "USD",
|
||||
"access_days": 365,
|
||||
"is_active": true,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upsert Course Product
|
||||
|
||||
**`PUT /api/admin/products/courses/:courseId/product`**
|
||||
|
||||
Creates the product if it does not exist. If a soft-deleted product exists, it is restored and updated. If an active product exists, it is updated.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Product name |
|
||||
| `price` | number | **Yes** | Price (decimal, e.g. `49.99`) |
|
||||
| `description` | string | No | |
|
||||
| `currency` | string | No | ISO 4217 code. Default: `USD` |
|
||||
| `access_days` | number | No | Days of access after purchase. `null` = lifetime |
|
||||
| `is_active` | boolean | No | Default: `true` |
|
||||
|
||||
### Response `200` (updated) / `201` (created)
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Product updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `name and price are required.` |
|
||||
|
||||
---
|
||||
|
||||
## Remove Course Product
|
||||
|
||||
**`DELETE /api/admin/products/courses/:courseId/product`**
|
||||
|
||||
Soft-deletes the course product.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Product removed." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Product not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Get Course Categories
|
||||
|
||||
**`GET /api/admin/products/courses/:courseId/categories`**
|
||||
|
||||
Returns the list of categories assigned to a course.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course categories retrieved.",
|
||||
"data": [
|
||||
{ "id": 1, "name": "Business", "slug": "business", "is_active": true },
|
||||
{ "id": 3, "name": "Design", "slug": "design", "is_active": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Course not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Sync Course Categories
|
||||
|
||||
**`POST /api/admin/products/courses/:courseId/categories`**
|
||||
|
||||
Replaces the full set of category assignments for a course. All existing assignments are removed first, then the new set is inserted.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "category_ids": [1, 3, 7] }
|
||||
```
|
||||
|
||||
Pass an empty array to clear all category assignments.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Course categories updated." }
|
||||
```
|
||||
@@ -0,0 +1,148 @@
|
||||
# Profile Controller Documentation (Admin)
|
||||
|
||||
**File:** `controllers/admin/profile.controller.js`
|
||||
**Base URL:** `/api/admin/profile`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get Profile](#get-profile)
|
||||
- [Update Profile](#update-profile)
|
||||
- [Upload Avatar](#upload-avatar)
|
||||
- [Delete Avatar](#delete-avatar)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All endpoints are self-service — they operate on the **currently authenticated admin** (`req.user.user_id`).
|
||||
- `password`, `otp_code`, and `otp_expires_at` are always excluded from responses.
|
||||
- Avatar files are stored in S3 via `s3.service`. The old avatar is deleted before upload.
|
||||
|
||||
---
|
||||
|
||||
## Get Profile
|
||||
|
||||
**`GET /api/admin/profile`**
|
||||
|
||||
Returns the authenticated admin's full user record.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Profile retrieved.",
|
||||
"data": {
|
||||
"user_id": 1,
|
||||
"email": "admin@example.com",
|
||||
"is_active": true,
|
||||
"is_verified": true,
|
||||
"reg_type": "system",
|
||||
"acc_type": "admin",
|
||||
"personal_info": {
|
||||
"name": {
|
||||
"given_name": "Kenneth",
|
||||
"middle_name": null,
|
||||
"last_name": "Obsequio",
|
||||
"extension_name": null,
|
||||
"full_name": "Kenneth Obsequio"
|
||||
},
|
||||
"occupation": null,
|
||||
"addresses": [],
|
||||
"phone_number": [],
|
||||
"date_of_birth": null,
|
||||
"avatar": {
|
||||
"url": "https://...",
|
||||
"uuid": "storage-key",
|
||||
"name": "avatar.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"size": 204800
|
||||
}
|
||||
},
|
||||
"createdAt": "2025-10-06T00:00:00.000Z",
|
||||
"updatedAt": "2026-06-18T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Profile
|
||||
|
||||
**`PUT /api/admin/profile`**
|
||||
|
||||
Deep-merges `personal_info` — top-level keys and `name` sub-keys are merged separately. Existing keys not present in the request body are preserved.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"personal_info": {
|
||||
"name": {
|
||||
"given_name": "Kenneth",
|
||||
"last_name": "Obsequio"
|
||||
},
|
||||
"occupation": "Software Engineer",
|
||||
"date_of_birth": "1995-05-15"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Profile updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upload Avatar
|
||||
|
||||
**`POST /api/admin/profile/avatar`**
|
||||
|
||||
Uploads (or replaces) the admin's avatar via `multipart/form-data`.
|
||||
|
||||
- Old avatar is deleted from S3 before uploading the new one.
|
||||
- Avatar metadata is stored in `personal_info.avatar`.
|
||||
|
||||
### Request
|
||||
`Content-Type: multipart/form-data`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `file` | file | **Yes** | Image file (handled by `avatar_upload.middleware`) |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Avatar updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `No file provided.` |
|
||||
|
||||
---
|
||||
|
||||
## Delete Avatar
|
||||
|
||||
**`DELETE /api/admin/profile/avatar`**
|
||||
|
||||
Removes the admin's avatar from S3 and sets `personal_info.avatar` to `null`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Avatar removed." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `No avatar to remove.` |
|
||||
@@ -0,0 +1,563 @@
|
||||
# Tasks Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/task.controller.js` + `controllers/admin/task_completion.controller.js`
|
||||
**Base URL:** `/api/admin/task-lists`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Task Lists](#task-lists)
|
||||
- [Get All Task Lists](#get-all-task-lists)
|
||||
- [Get Single Task List](#get-single-task-list)
|
||||
- [Create Task List](#create-task-list)
|
||||
- [Update Task List](#update-task-list)
|
||||
- [Archive Task List](#archive-task-list)
|
||||
- [Restore Task List](#restore-task-list)
|
||||
- [Bulk Archive Task Lists](#bulk-archive-task-lists)
|
||||
- [Bulk Restore Task Lists](#bulk-restore-task-lists)
|
||||
- [Get Archived Task Lists](#get-archived-task-lists)
|
||||
- [Get Task List Field Values](#get-task-list-field-values)
|
||||
- [Task List Groups](#task-list-groups)
|
||||
- [Get Assigned Groups](#get-assigned-groups)
|
||||
- [Assign Groups](#assign-groups)
|
||||
- [Unassign Groups](#unassign-groups)
|
||||
- [Tasks](#tasks)
|
||||
- [Get All Tasks](#get-all-tasks)
|
||||
- [Get Single Task](#get-single-task)
|
||||
- [Create Task](#create-task)
|
||||
- [Update Task](#update-task)
|
||||
- [Archive Task](#archive-task)
|
||||
- [Restore Task](#restore-task)
|
||||
- [Bulk Archive Tasks](#bulk-archive-tasks)
|
||||
- [Bulk Restore Tasks](#bulk-restore-tasks)
|
||||
- [Get Archived Tasks](#get-archived-tasks)
|
||||
- [Get Task Field Values](#get-task-field-values)
|
||||
- [Completions](#completions)
|
||||
- [Get All Completions](#get-all-completions)
|
||||
- [Get Single Completion](#get-single-completion)
|
||||
- [Get Completions by User](#get-completions-by-user)
|
||||
- [Archive Completion](#archive-completion)
|
||||
- [Restore Completion](#restore-completion)
|
||||
- [Bulk Archive Completions](#bulk-archive-completions)
|
||||
- [Bulk Restore Completions](#bulk-restore-completions)
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
```
|
||||
TaskList ──< Task ──< TaskRequirement
|
||||
│
|
||||
└──< TaskListGroup >── UserGroup
|
||||
```
|
||||
|
||||
- A **TaskList** is a named container of tasks, visible to assigned user groups.
|
||||
- A **Task** belongs to one TaskList and has 0-N requirements.
|
||||
- A **TaskRequirement** specifies what a user must do (visit a link, upload a file, or read a course/unit/lesson).
|
||||
- Completions are submitted by **clients only** — admins can view and archive/restore them.
|
||||
|
||||
---
|
||||
|
||||
## Task Requirement Types
|
||||
|
||||
| `type` | Required Fields | Description |
|
||||
|--------|----------------|-------------|
|
||||
| `visit_link` | `link_url`, `link_label` | User must visit a URL |
|
||||
| `upload_file` | `allowed_file_types`, `max_file_count` | User must upload files |
|
||||
| `read_course` | `reference_id` (course UUID), `reference_label` | User must complete a course |
|
||||
| `read_unit` | `reference_id` (unit UUID), `reference_label` | User must complete a unit |
|
||||
| `read_lesson` | `reference_id` (lesson UUID), `reference_label` | User must complete a lesson |
|
||||
|
||||
---
|
||||
|
||||
## Task Lists
|
||||
|
||||
### Get All Task Lists
|
||||
|
||||
**`GET /api/admin/task-lists`**
|
||||
|
||||
Returns paginated active task lists.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
| `filters` | array | No | JSON filter array |
|
||||
| `sort` | array | No | JSON sort array |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task lists retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Task List
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId`**
|
||||
|
||||
Returns a task list with its full task tree (tasks → requirements) and assigned groups. Includes archived items (`paranoid: false`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task list retrieved.",
|
||||
"data": {
|
||||
"task_list_id": "uuid-...",
|
||||
"name": "Onboarding Q1",
|
||||
"description": "...",
|
||||
"group_count": 2,
|
||||
"groups": [
|
||||
{ "group_id": 1, "name": "Engineering" }
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "uuid-...",
|
||||
"name": "Read the handbook",
|
||||
"deadline": "2026-07-01T00:00:00.000Z",
|
||||
"status": "pending",
|
||||
"requirements": [
|
||||
{ "requirement_id": "uuid-...", "type": "read_lesson", "reference_id": "uuid-..." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"createdAt": "...",
|
||||
"updatedAt": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Task List
|
||||
|
||||
**`POST /api/admin/task-lists`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Unique list name |
|
||||
| `description` | string | No | |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task list created successfully.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Task List
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | No | |
|
||||
| `description` | string | No | |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Task list updated successfully.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Archive Task List
|
||||
|
||||
**`DELETE /api/admin/task-lists/:taskListId`**
|
||||
|
||||
Rate-limited. Soft-deletes the task list.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Task list archived successfully." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Restore Task List
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Task list restored successfully.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Task Lists
|
||||
|
||||
**`POST /api/admin/task-lists/bulk-archive`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": ["uuid-1", "uuid-2"] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 task list(s) archived successfully.",
|
||||
"archived_ids": [...],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Task Lists
|
||||
|
||||
**`POST /api/admin/task-lists/bulk-restore`**
|
||||
|
||||
Rate-limited. Same body shape as bulk archive.
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Task Lists
|
||||
|
||||
**`GET /api/admin/task-lists/archived`**
|
||||
|
||||
Returns paginated soft-deleted task lists.
|
||||
|
||||
---
|
||||
|
||||
### Get Task List Field Values
|
||||
|
||||
**`GET /api/admin/task-lists/field-values`**
|
||||
|
||||
Returns distinct filterable field values for task lists.
|
||||
|
||||
---
|
||||
|
||||
## Task List Groups
|
||||
|
||||
### Get Assigned Groups
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/groups`**
|
||||
|
||||
Returns all user groups currently assigned to the task list, including assignment metadata.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task list groups retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid-...",
|
||||
"task_list_id": "uuid-...",
|
||||
"group_id": 1,
|
||||
"assignedAt": "2026-06-01T00:00:00.000Z",
|
||||
"assignedBy": 1,
|
||||
"group": { "group_id": 1, "name": "Engineering" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Assign Groups
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/groups/assign`**
|
||||
|
||||
Rate-limited. Upsert-style — already-assigned groups are silently skipped.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "group_ids": [1, 2, 3] }
|
||||
```
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 group(s) assigned.",
|
||||
"assigned_ids": [2, 3],
|
||||
"already_assigned_ids": [1],
|
||||
"invalid_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Unassign Groups
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/groups/unassign`**
|
||||
|
||||
Rate-limited. Hard-deletes the junction rows (assignments are not soft-deleted).
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "group_ids": [2, 3] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 group(s) unassigned.",
|
||||
"unassigned_ids": [2, 3],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Get All Tasks
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks`**
|
||||
|
||||
Returns paginated tasks under a task list.
|
||||
|
||||
### Query Parameters
|
||||
Standard pagination + `filters` + `sort`.
|
||||
|
||||
---
|
||||
|
||||
### Get Single Task
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId`**
|
||||
|
||||
Returns a task with its requirements and the parent task list (including assigned groups).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task retrieved.",
|
||||
"data": {
|
||||
"task_id": "uuid-...",
|
||||
"task_list_id": "uuid-...",
|
||||
"name": "Complete orientation",
|
||||
"description": "...",
|
||||
"deadline": "2026-07-01T00:00:00.000Z",
|
||||
"status": "pending",
|
||||
"requirements": [
|
||||
{
|
||||
"requirement_id": "uuid-...",
|
||||
"type": "upload_file",
|
||||
"allowed_file_types": ["application/pdf"],
|
||||
"max_file_count": 1,
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"taskList": { "task_list_id": "...", "name": "Onboarding Q1", "groups": [...] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Task
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks`**
|
||||
|
||||
Rate-limited. Creates a task and optionally its requirements in a single transaction.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Task name |
|
||||
| `description` | string | No | |
|
||||
| `deadline` | date | No | ISO date string |
|
||||
| `requirements` | array | No | Array of requirement objects (see Requirement Types above) |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task created successfully.",
|
||||
"data": { "task_id": "uuid-...", "name": "...", "requirements": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Task
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId`**
|
||||
|
||||
Rate-limited. When `requirements` is provided, the full set is replaced (soft-delete + re-insert).
|
||||
|
||||
> **Note:** Incoming requirement objects must **not** include `requirement_id` — the server always generates fresh IDs to avoid collisions with soft-deleted rows.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | No | |
|
||||
| `description` | string | No | |
|
||||
| `deadline` | date | No | |
|
||||
| `status` | string | No | `pending`, `in_progress`, `completed`, `overdue` |
|
||||
| `requirements` | array | No | Full replacement set |
|
||||
|
||||
---
|
||||
|
||||
### Archive Task
|
||||
|
||||
**`DELETE /api/admin/task-lists/:taskListId/tasks/:taskId`**
|
||||
|
||||
Rate-limited. Soft-deletes the task.
|
||||
|
||||
---
|
||||
|
||||
### Restore Task
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Tasks
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/bulk-archive`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": ["uuid-1", "uuid-2"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Tasks
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/bulk-restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Tasks
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/archived`**
|
||||
|
||||
---
|
||||
|
||||
### Get Task Field Values
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Completions
|
||||
|
||||
Completions are created by clients only. Admins can view and archive/restore them.
|
||||
|
||||
Each completion may have multiple attached files (`task_completion_files`).
|
||||
|
||||
---
|
||||
|
||||
### Get All Completions
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions`**
|
||||
|
||||
Returns paginated completions for a task, with submitting user info and files.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Completions retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"completion_id": "uuid-...",
|
||||
"task_id": "uuid-...",
|
||||
"user_id": 42,
|
||||
"note": "Attached the signed form.",
|
||||
"submitted_at": "2026-06-15T10:00:00.000Z",
|
||||
"user": { "user_id": 42, "email": "user@example.com", "name": "Jane Doe" },
|
||||
"files": [
|
||||
{
|
||||
"file_id": "uuid-...",
|
||||
"file_url": "https://...",
|
||||
"file_name": "signed_form.pdf",
|
||||
"file_size": 204800,
|
||||
"mime_type": "application/pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"pagination": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Completion
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId`**
|
||||
|
||||
Returns a single completion with user and files.
|
||||
|
||||
---
|
||||
|
||||
### Get Completions by User
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId`**
|
||||
|
||||
Returns all completions for a specific user on a specific task.
|
||||
|
||||
---
|
||||
|
||||
### Archive Completion
|
||||
|
||||
**`DELETE /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId`**
|
||||
|
||||
Rate-limited. Soft-deletes a completion.
|
||||
|
||||
---
|
||||
|
||||
### Restore Completion
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Completions
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": ["uuid-1", "uuid-2"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Completions
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore`**
|
||||
|
||||
Rate-limited.
|
||||
@@ -0,0 +1,406 @@
|
||||
# Tiers Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/tiers.controller.js`
|
||||
**Base URL:** `/api/admin/tiers`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Tier Plans](#tier-plans)
|
||||
- [Get All Plans](#get-all-plans)
|
||||
- [Get Single Plan](#get-single-plan)
|
||||
- [Create Plan](#create-plan)
|
||||
- [Update Plan](#update-plan)
|
||||
- [Archive Plan](#archive-plan)
|
||||
- [Bulk Archive Plans](#bulk-archive-plans)
|
||||
- [Restore Plan](#restore-plan)
|
||||
- [Bulk Restore Plans](#bulk-restore-plans)
|
||||
- [Get Plan Field Values](#get-plan-field-values)
|
||||
- [Plan Courses](#plan-courses)
|
||||
- [Get Plan Courses](#get-plan-courses)
|
||||
- [Sync Plan Courses](#sync-plan-courses)
|
||||
- [User Tiers](#user-tiers)
|
||||
- [Get User Tiers](#get-user-tiers)
|
||||
- [Grant Tier](#grant-tier)
|
||||
- [Revoke Tier](#revoke-tier)
|
||||
- [Payments](#payments)
|
||||
- [Get All Payments](#get-all-payments)
|
||||
- [Get Single Payment](#get-single-payment)
|
||||
- [Get Payment Field Values](#get-payment-field-values)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Pending payments older than **60 minutes** are automatically expired before any payment list/detail call.
|
||||
- Revoking a tier immediately creates a new `free` tier row for the user (auto-downgrade).
|
||||
- `plan_id` is serialized as a string in create responses to avoid BigInt overflow in JS.
|
||||
- A plan's `tier` field cannot be updated — only `label`, `duration_days`, `price`, `currency`, and `is_active`.
|
||||
|
||||
---
|
||||
|
||||
## Tier Plans
|
||||
|
||||
### Get All Plans
|
||||
|
||||
**`GET /api/admin/tiers`**
|
||||
|
||||
Returns paginated plans. Pass `?archived=true` to see soft-deleted plans instead.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|---------|----------|-------------|
|
||||
| `archived` | boolean | No | `true` to show archived plans only |
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plans retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 4, "totalPages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Plan
|
||||
|
||||
**`GET /api/admin/tiers/:id`**
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plan retrieved.",
|
||||
"data": {
|
||||
"plan_id": 1,
|
||||
"tier": "premium",
|
||||
"label": "Premium Monthly",
|
||||
"duration_days": 30,
|
||||
"price": "9.99",
|
||||
"currency": "USD",
|
||||
"is_active": true,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Plan
|
||||
|
||||
**`POST /api/admin/tiers`**
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `tier` | string | **Yes** | `premium` or `exclusive` |
|
||||
| `label` | string | **Yes** | Human-readable plan name |
|
||||
| `duration_days` | number | **Yes** | Access duration in days |
|
||||
| `price` | number | **Yes** | Plan price (decimal) |
|
||||
| `currency` | string | No | ISO 4217. Default: `USD` |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plan created.",
|
||||
"data": { "plan_id": "5", "tier": "premium", ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Plan
|
||||
|
||||
**`PUT /api/admin/tiers/:id`**
|
||||
|
||||
Only `label`, `duration_days`, `price`, `currency`, and `is_active` are updatable.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan updated.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Archive Plan
|
||||
|
||||
**`DELETE /api/admin/tiers/:id`**
|
||||
|
||||
Sets `is_active = false` then soft-deletes.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan archived successfully." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Plans
|
||||
|
||||
**`POST /api/admin/tiers/bulk/archive`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 plan(s) archived successfully.",
|
||||
"archived_ids": [1, 2],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Restore Plan
|
||||
|
||||
**`POST /api/admin/tiers/:id/restore`**
|
||||
|
||||
Restores plan and sets `is_active = true`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan restored successfully." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Plans
|
||||
|
||||
**`POST /api/admin/tiers/bulk/restore`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 plan(s) restored successfully.",
|
||||
"restored_ids": [1, 2],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Plan Field Values
|
||||
|
||||
**`GET /api/admin/tiers/field-values`**
|
||||
|
||||
Returns distinct filterable field values for tier plans.
|
||||
|
||||
---
|
||||
|
||||
## Plan Courses
|
||||
|
||||
### Get Plan Courses
|
||||
|
||||
**`GET /api/admin/tiers/:id/courses`**
|
||||
|
||||
Returns the list of courses linked to this plan.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plan courses retrieved.",
|
||||
"data": [
|
||||
{ "course_id": 1, "title": "Intro to Python", "course_code": "PY-101", "subscription": "premium", "level": "beginner" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync Plan Courses
|
||||
|
||||
**`POST /api/admin/tiers/:id/courses`**
|
||||
|
||||
Replaces the full set of courses for this plan. Removes all existing assignments first, then inserts the new set. A course can only belong to one plan at a time — existing assignments to other plans are cleared automatically.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "course_ids": [1, 2, 3] }
|
||||
```
|
||||
|
||||
Pass `[]` to remove all courses from the plan.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan courses updated." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Tiers
|
||||
|
||||
### Get User Tiers
|
||||
|
||||
**`GET /api/admin/tiers/users/:id/tiers`**
|
||||
|
||||
Returns the full tier history for a user, newest first. Includes `grantedByUser` and `revokedByUser` info.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User tiers retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"tier_id": 3,
|
||||
"user_id": 42,
|
||||
"tier": "premium",
|
||||
"status": "active",
|
||||
"starts_at": "2026-06-01T00:00:00.000Z",
|
||||
"expires_at": "2026-07-01T00:00:00.000Z",
|
||||
"granted_by": 1,
|
||||
"revoked_by": null,
|
||||
"revoked_at": null,
|
||||
"notes": "Trial promotion",
|
||||
"grantedByUser": { "user_id": 1, "email": "admin@example.com" },
|
||||
"revokedByUser": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Grant Tier
|
||||
|
||||
**`POST /api/admin/tiers/users/tiers/grant`**
|
||||
|
||||
- Expires all currently active tiers for the user before creating the new one.
|
||||
- `expires_at` is calculated as `now + plan.duration_days`.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `user_id` | number | **Yes** | Target user |
|
||||
| `tier` | string | **Yes** | `premium` or `exclusive` |
|
||||
| `plan_id` | number | **Yes** | Must match the plan's tier |
|
||||
| `notes` | string | No | Optional admin note |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Tier granted.",
|
||||
"data": { "tier_id": 4, "user_id": 42, "tier": "premium", "status": "active", ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `user_id, tier, and plan_id are required.` |
|
||||
| `400` | `Plan tier mismatch.` |
|
||||
| `404` | `User not found.` |
|
||||
| `404` | `Plan not found or inactive.` |
|
||||
|
||||
---
|
||||
|
||||
### Revoke Tier
|
||||
|
||||
**`PATCH /api/admin/tiers/users/tiers/:tid/revoke`**
|
||||
|
||||
- Sets the tier's status to `revoked` and records `revoked_by` / `revoked_at`.
|
||||
- Automatically creates a new `free` tier row for the user (auto-downgrade note: `"Auto-downgrade after revoke."`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Tier revoked. User downgraded to free." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `Tier is not active.` |
|
||||
| `404` | `Tier record not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Payments
|
||||
|
||||
### Get All Payments
|
||||
|
||||
**`GET /api/admin/tiers/payments`**
|
||||
|
||||
Returns paginated payment records. Stale pending payments (>60 min old) are expired before the query runs.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
| `filters` | array | No | JSON filter array |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Payments retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"payment_id": 1,
|
||||
"user_id": 42,
|
||||
"plan_id": 1,
|
||||
"status": "completed",
|
||||
"amount": "9.99",
|
||||
"currency": "USD",
|
||||
"promo_code": null,
|
||||
"discount": "0.00",
|
||||
"provider": "paypal",
|
||||
"paid_at": "2026-06-01T10:00:00.000Z",
|
||||
"user": { "user_id": 42, "email": "user@example.com" },
|
||||
"plan": { "plan_id": 1, "label": "Premium Monthly", "tier": "premium", "duration_days": 30 }
|
||||
}
|
||||
],
|
||||
"pagination": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Payment
|
||||
|
||||
**`GET /api/admin/tiers/payments/:id`**
|
||||
|
||||
Returns full payment detail including `user`, `plan`, and `tier` associations.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Payment retrieved.",
|
||||
"data": {
|
||||
"payment_id": 1,
|
||||
"provider_payload": { "order_id": "...", "capture_id": "...", ... },
|
||||
"user": { ... },
|
||||
"plan": { ... },
|
||||
"tier": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Payment Field Values
|
||||
|
||||
**`GET /api/admin/tiers/payments/field-values`**
|
||||
|
||||
Returns distinct filterable field values for payments. `provider_payload` is excluded.
|
||||
@@ -0,0 +1,129 @@
|
||||
# User Activity Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/user_activity.controller.js`
|
||||
**Base URL:** `/api/admin/activity` and `/api/admin/users/:id/activity`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get Global Activity Feed](#get-global-activity-feed)
|
||||
- [Get Per-User Activity](#get-per-user-activity)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `user_activity` rows are **never soft-deleted** — this is an append-only audit log.
|
||||
- `created_at` is the timestamp column (no `createdAt` alias — Sequelize `timestamps: false`).
|
||||
- The global feed endpoint joins the `users` table and enriches each row with `full_name` and `avatar_url`.
|
||||
- The per-user endpoint skips the join for performance since user context is already known.
|
||||
|
||||
---
|
||||
|
||||
## Activity Object Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": 1,
|
||||
"user_id": 42,
|
||||
"email": "user@example.com",
|
||||
"full_name": "Kenneth Obsequio",
|
||||
"avatar_url": "https://...",
|
||||
"acc_type": "admin",
|
||||
"action": "login",
|
||||
"entity_type": "session",
|
||||
"entity_id": 7,
|
||||
"details": { "session_id": 7, "reg_type": "system" },
|
||||
"created_at": "2026-06-21T08:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Common `action` Values
|
||||
| Action | Entity Type | Details Keys |
|
||||
|--------|-------------|--------------|
|
||||
| `login` | `session` | `session_id`, `reg_type` |
|
||||
| `deactivate_user` | `user` | `target_email` |
|
||||
| `lesson_read` | `lesson` | `lesson_uuid`, `status` |
|
||||
| `submit_task` | `task` | `task_id` |
|
||||
| `set_user_status` | `user` | `is_active` |
|
||||
| `create_course` | `course` | `title` |
|
||||
| `update_course` | `course` | `title` |
|
||||
| `archive_course` | `course` | — |
|
||||
| `create_task_list` | `task_list` | `name` |
|
||||
| `create_advertisement` | `advertisement` | `type` |
|
||||
| `grant_tier` | `tier` | `user_id`, `tier`, `plan_id` |
|
||||
| `revoke_tier` | `tier` | `user_id`, `tier` |
|
||||
|
||||
---
|
||||
|
||||
## Get Global Activity Feed
|
||||
|
||||
**`GET /api/admin/activity`**
|
||||
|
||||
Returns a paginated, reverse-chronological feed of all user activity across the system.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `20`, max: `100` |
|
||||
| `action` | string | No | Filter by exact action string (e.g. `login`) |
|
||||
| `from` | date | No | ISO date — `created_at >= from` |
|
||||
| `to` | date | No | ISO date — `created_at <= to` |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Activity feed retrieved.",
|
||||
"data": {
|
||||
"total": 500,
|
||||
"page": 1,
|
||||
"totalPages": 25,
|
||||
"activities": [ ... ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Per-User Activity
|
||||
|
||||
**`GET /api/admin/users/:id/activity`**
|
||||
|
||||
Returns a paginated, reverse-chronological activity log for a single user.
|
||||
|
||||
### Query Parameters
|
||||
Same as global feed (`page`, `limit`, `action`, `from`, `to`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User activity retrieved.",
|
||||
"data": {
|
||||
"total": 45,
|
||||
"page": 1,
|
||||
"totalPages": 3,
|
||||
"activities": [
|
||||
{
|
||||
"activity_id": 1,
|
||||
"user_id": 42,
|
||||
"session_id": null,
|
||||
"action": "update_course",
|
||||
"entity_type": "course",
|
||||
"entity_id": 3,
|
||||
"details": { "title": "Advanced JS" },
|
||||
"created_at": "2026-06-21T09:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `Invalid User ID.` |
|
||||
| `404` | `User not found.` |
|
||||
@@ -0,0 +1,491 @@
|
||||
# User Groups Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/user_groups.controller.js`
|
||||
**Base URL:** `/api/admin/groups`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Groups](#get-all-groups)
|
||||
- [Get Single Group](#get-single-group)
|
||||
- [Create Group](#create-group)
|
||||
- [Update Group](#update-group)
|
||||
- [Deactivate Group](#deactivate-group)
|
||||
- [Bulk Deactivate Groups](#bulk-deactivate-groups)
|
||||
- [Restore Group](#restore-group)
|
||||
- [Bulk Restore Groups](#bulk-restore-groups)
|
||||
- [Get Archived Groups](#get-archived-groups)
|
||||
- [Get Group Field Values](#get-group-field-values)
|
||||
- [Get Users In Group](#get-users-in-group)
|
||||
- [Get Users Not In Group](#get-users-not-in-group)
|
||||
- [Add Users To Group](#add-users-to-group)
|
||||
- [Remove Users From Group](#remove-users-from-group)
|
||||
|
||||
---
|
||||
|
||||
## Get All Groups
|
||||
|
||||
**`GET /api/admin/groups`**
|
||||
|
||||
Returns a paginated list of active groups.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------|--------|----------|--------------------------------------------------|
|
||||
| page | number | No | Page number. Default: `1` |
|
||||
| limit | number | No | Records per page. Default: `20` |
|
||||
| search | string | No | Search across group fields |
|
||||
| sort_by | string | No | Column to sort by. Default: `createdAt` |
|
||||
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
|
||||
| filters | array | No | Column filters from DataTable |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Groups retrieved.",
|
||||
"data": {
|
||||
"rows": [
|
||||
{
|
||||
"group_id": 1,
|
||||
"name": "Administrators",
|
||||
"description": "Full access group.",
|
||||
"is_active": true,
|
||||
"member_count": 5,
|
||||
"createdBy": 1,
|
||||
"updatedBy": null,
|
||||
"deletedBy": null,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 10,
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"totalPages": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Single Group
|
||||
|
||||
**`GET /api/admin/groups/:gid`**
|
||||
|
||||
Returns a single group with a paginated list of its members.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Query Parameters
|
||||
Same pagination/filter params as [Get All Groups](#get-all-groups) — applied to the members list.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group retrieved.",
|
||||
"data": {
|
||||
"group": {
|
||||
"group_id": 1,
|
||||
"name": "Administrators",
|
||||
"description": "Full access group.",
|
||||
"is_active": true,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
},
|
||||
"members": {
|
||||
"rows": [ ...users ],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Create Group
|
||||
|
||||
**`POST /api/admin/groups`**
|
||||
|
||||
Creates a new user group.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|------------|--------|----------|--------------------|
|
||||
| name | string | Yes | Group name |
|
||||
| description | string | No | Group description |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group created.",
|
||||
"data": {
|
||||
"group_id": 1,
|
||||
"name": "Administrators",
|
||||
"description": "Full access group.",
|
||||
"is_active": true,
|
||||
"createdBy": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group name is required."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Group
|
||||
|
||||
**`PUT /api/admin/groups/:gid`**
|
||||
|
||||
Updates a group's name or description.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|------------|--------|----------|------------------------|
|
||||
| name | string | No | Updated group name |
|
||||
| description | string | No | Updated description |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group updated.",
|
||||
"data": { ...group }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deactivate Group
|
||||
|
||||
**`PATCH /api/admin/groups/:gid/deactivate`**
|
||||
|
||||
Soft deletes a group by setting `deletedAt` and `is_active: false`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group deactivated."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group is already deactivated."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Deactivate Groups
|
||||
|
||||
**`DELETE /api/admin/groups/bulk`**
|
||||
|
||||
Soft deletes multiple groups at once.
|
||||
Already-deactivated groups are skipped and reported.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|---------------------------|
|
||||
| ids | number[] | Yes | Array of group IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 group(s) deactivated successfully.",
|
||||
"data": {
|
||||
"deactivated_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore Group
|
||||
|
||||
**`PATCH /api/admin/groups/:gid/restore`**
|
||||
|
||||
Restores a soft-deleted group by clearing `deletedAt` and setting `is_active: true`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group restored."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group is already active."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Restore Groups
|
||||
|
||||
**`POST /api/admin/groups/bulk/restore`**
|
||||
|
||||
Restores multiple soft-deleted groups at once.
|
||||
Already-active groups are skipped and reported.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|---------------------------|
|
||||
| ids | number[] | Yes | Array of group IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 group(s) restored successfully.",
|
||||
"data": {
|
||||
"restored_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Archived Groups
|
||||
|
||||
**`GET /api/admin/groups/archived`**
|
||||
|
||||
Returns a paginated list of soft-deleted groups.
|
||||
|
||||
### Query Parameters
|
||||
Same as [Get All Groups](#get-all-groups).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Archived groups retrieved.",
|
||||
"data": {
|
||||
"rows": [ ...soft-deleted groups ],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Group Field Values
|
||||
|
||||
**`GET /api/admin/groups/field-values`**
|
||||
|
||||
Returns distinct values for a given column — used to populate filter dropdowns in the DataTable.
|
||||
Supports regular columns, date fields, and audit fields.
|
||||
JSONB fields are not supported for groups.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------|--------|----------|--------------------|
|
||||
| field | string | Yes | Column name |
|
||||
|
||||
### Supported Field Types
|
||||
| Type | Example | Returns |
|
||||
|----------|-------------|--------------------------------|
|
||||
| Regular | `is_active` | Distinct values |
|
||||
| Date | `createdAt` | Distinct dates (no time) |
|
||||
| Audit by | `createdBy` | Full names of referenced users |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Field values retrieved.",
|
||||
"data": ["true", "false"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Users In Group
|
||||
|
||||
**`GET /api/admin/groups/:gid/users`**
|
||||
|
||||
Returns all current members of a group with their `user_id` and `full_name`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group members fetched.",
|
||||
"data": [
|
||||
{ "user_id": 1, "full_name": "John Doe" },
|
||||
{ "user_id": 2, "full_name": "Jane Smith" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Users Not In Group
|
||||
|
||||
**`GET /api/admin/groups/:gid/users/add`**
|
||||
|
||||
Returns all users who are **not** currently members of the group.
|
||||
Used to populate the Add Members sheet.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users fetched.",
|
||||
"data": [
|
||||
{ "user_id": 3, "full_name": "Alice Johnson" },
|
||||
{ "user_id": 4, "full_name": "Bob Williams" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Add Users To Group
|
||||
|
||||
**`POST /api/admin/groups/:gid/users`**
|
||||
|
||||
Adds one or more users to a group.
|
||||
If a user was previously removed (soft-deleted membership), their membership is restored instead of duplicated.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|---------|----------|----------|------------------------------|
|
||||
| user_ids | number[] | Yes | Array of user IDs to add |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users added to group."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Users not found: 5, 6"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Remove Users From Group
|
||||
|
||||
**`DELETE /api/admin/groups/:gid/users`**
|
||||
|
||||
Removes one or more users from a group via soft delete on the membership record.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|---------|----------|---------|---------------------------------|
|
||||
| user_ids | number[] | Yes | Array of user IDs to remove |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users removed from group."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Memberships not found for users: 5, 6"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return the following on server error:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Internal server error."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Soft delete** — deactivation sets `deletedAt` + `is_active: false`. Groups are excluded from all queries unless explicitly queried with `paranoid: false`.
|
||||
- **Membership soft delete** — removing a user from a group soft-deletes the membership record. Re-adding the user restores the record rather than creating a duplicate.
|
||||
- **Audit fields** — `createdBy`, `updatedBy`, `deletedBy` store the `user_id` of the admin who performed the action.
|
||||
- **JSONB** — group fields do not support JSONB dot-notation filtering unlike users.
|
||||
@@ -0,0 +1,463 @@
|
||||
# Users Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/users.controller.js`
|
||||
**Base URL:** `/api/admin/users`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Users](#get-all-users)
|
||||
- [Get Single User](#get-single-user)
|
||||
- [Add Staff User](#add-staff-user)
|
||||
- [Update User](#update-user)
|
||||
- [Deactivate User](#deactivate-user)
|
||||
- [Bulk Deactivate Users](#bulk-deactivate-users)
|
||||
- [Restore User](#restore-user)
|
||||
- [Bulk Restore Users](#bulk-restore-users)
|
||||
- [Get Archived Users](#get-archived-users)
|
||||
- [Get User Field Values](#get-user-field-values)
|
||||
- [Get User Sessions](#get-user-sessions)
|
||||
- [Terminate Session](#terminate-session)
|
||||
|
||||
---
|
||||
|
||||
## Get All Users
|
||||
|
||||
**`GET /api/admin/users`**
|
||||
|
||||
Returns a paginated list of active users.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|--------------------------------------------------|
|
||||
| page | number | No | Page number. Default: `1` |
|
||||
| limit | number | No | Records per page. Default: `20` |
|
||||
| search | string | No | Search across user fields |
|
||||
| sort_by | string | No | Column to sort by. Default: `createdAt` |
|
||||
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
|
||||
| filters | array | No | Column filters from DataTable |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users retrieved.",
|
||||
"data": {
|
||||
"rows": [
|
||||
{
|
||||
"user_id": 1,
|
||||
"email": "john@example.com",
|
||||
"acc_type": "admin",
|
||||
"reg_type": "system",
|
||||
"is_active": true,
|
||||
"is_verified": true,
|
||||
"personal_info": {
|
||||
"name": {
|
||||
"given_name": "John",
|
||||
"middle_name": null,
|
||||
"last_name": "Doe",
|
||||
"extension_name": null,
|
||||
"full_name": "John Doe"
|
||||
},
|
||||
"date_of_birth": null,
|
||||
"occupation": null,
|
||||
"addresses": [],
|
||||
"phone_number": []
|
||||
},
|
||||
"groups": [],
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"totalPages": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Single User
|
||||
|
||||
**`GET /api/admin/users/:id`**
|
||||
|
||||
Returns a single user with their group memberships.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User retrieved.",
|
||||
"data": {
|
||||
"user_id": 1,
|
||||
"email": "john@example.com",
|
||||
"acc_type": "admin",
|
||||
"is_active": true,
|
||||
"is_verified": true,
|
||||
"personal_info": { ... },
|
||||
"groups": [
|
||||
{ "group_id": 1, "name": "Administrators" }
|
||||
],
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "User not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Add Staff User
|
||||
|
||||
**`POST /api/admin/users/staff`**
|
||||
|
||||
Creates a new staff user with an auto-generated temporary password.
|
||||
A welcome email is sent with the credentials and a 24-hour expiry notice.
|
||||
The user is forced to change their password on first login.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-----------------------------|--------|----------|--------------------------------------|
|
||||
| email | string | Yes | Staff user email address |
|
||||
| personal_info.name.given_name | string | Yes | First name |
|
||||
| personal_info.name.last_name | string | Yes | Last name |
|
||||
| personal_info.name.middle_name | string | No | Middle name |
|
||||
| personal_info.name.extension_name | string | No | Extension name e.g. `Jr.` |
|
||||
| personal_info.date_of_birth | string | No | Date of birth |
|
||||
| personal_info.occupation | string | No | Occupation |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Staff user created successfully.",
|
||||
"data": {
|
||||
"user_id": 5,
|
||||
"email": "staff@example.com",
|
||||
"acc_type": "staff"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `409`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Email is already in use."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update User
|
||||
|
||||
**`PUT /api/admin/users/:id`**
|
||||
|
||||
Updates a user's account type, active status, or personal information.
|
||||
Admins cannot change their own `acc_type`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|---------|----------|----------------------------------------------|
|
||||
| acc_type | string | No | `admin`, `staff`, `user` |
|
||||
| is_active | boolean | No | Active status |
|
||||
| personal_info | object | No | Personal information object |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User updated.",
|
||||
"data": { ...user }
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Admins cannot change their own role."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deactivate User
|
||||
|
||||
**`DELETE /api/admin/users/:id`**
|
||||
|
||||
Soft deletes a user by setting `deletedAt` and `is_active: false`.
|
||||
All active sessions are force-terminated.
|
||||
Admins cannot deactivate their own account.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User deactivated successfully."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "You cannot deactivate your own account."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Deactivate Users
|
||||
|
||||
**`DELETE /api/admin/users/bulk`**
|
||||
|
||||
Soft deletes multiple users at once.
|
||||
Already-deactivated users are skipped and reported.
|
||||
All active sessions for deactivated users are force-terminated.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|--------------------------|
|
||||
| ids | number[] | Yes | Array of user IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 user(s) deactivated successfully.",
|
||||
"data": {
|
||||
"deactivated_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore User
|
||||
|
||||
**`POST /api/admin/users/:id/restore`**
|
||||
|
||||
Restores a soft-deleted user by clearing `deletedAt` and setting `is_active: true`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User restored successfully."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "User is not deactivated."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Restore Users
|
||||
|
||||
**`POST /api/admin/users/bulk/restore`**
|
||||
|
||||
Restores multiple soft-deleted users at once.
|
||||
Already-active users are skipped and reported.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|--------------------------|
|
||||
| ids | number[] | Yes | Array of user IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 user(s) restored successfully.",
|
||||
"data": {
|
||||
"restored_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Archived Users
|
||||
|
||||
**`GET /api/admin/users/archived`**
|
||||
|
||||
Returns a paginated list of soft-deleted users.
|
||||
|
||||
### Query Parameters
|
||||
Same as [Get All Users](#get-all-users).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Archived users retrieved.",
|
||||
"data": {
|
||||
"rows": [ ...soft-deleted users ],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get User Field Values
|
||||
|
||||
**`GET /api/admin/users/field-values`**
|
||||
|
||||
Returns distinct values for a given column — used to populate filter dropdowns in the DataTable.
|
||||
Supports regular columns, date fields, audit fields, and JSONB dot-notation.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------|--------|----------|------------------------------------------------------|
|
||||
| field | string | Yes | Column name or JSONB path e.g. `personal_info.name.given_name` |
|
||||
|
||||
### Supported Field Types
|
||||
| Type | Example | Returns |
|
||||
|-------------|----------------------------------|--------------------------------|
|
||||
| Regular | `acc_type` | Distinct string values |
|
||||
| Date | `createdAt` | Distinct dates (no time) |
|
||||
| Audit by | `createdBy` | Full names of referenced users |
|
||||
| JSONB | `personal_info.name.given_name` | Distinct JSONB path values |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Field values retrieved.",
|
||||
"data": ["admin", "staff", "user"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Invalid or restricted field."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get User Sessions
|
||||
|
||||
**`GET /api/admin/users/:id/sessions`**
|
||||
|
||||
Returns all sessions for a specific user, ordered by most recent.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Sessions retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"session_id": 1,
|
||||
"user_id": 1,
|
||||
"is_active": true,
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"logout_info": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Terminate Session
|
||||
|
||||
**`DELETE /api/admin/users/:id/sessions/:sid`**
|
||||
|
||||
Force-terminates a specific user session.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|--------------|
|
||||
| id | number | Yes | User ID |
|
||||
| sid | number | Yes | Session ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Session terminated."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Session not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return the following on server error:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Internal server error."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Excluded fields** — `password`, `otp_code`, `otp_expires_at`, `must_change_password`, `password_expires_at` are never returned in any response.
|
||||
- **Soft delete** — deactivation sets `deletedAt` + `is_active: false`. Users are excluded from all queries unless explicitly queried with `paranoid: false`.
|
||||
- **Session termination** — deactivating a user (single or bulk) always force-terminates all their active sessions.
|
||||
- **Audit fields** — `createdBy`, `updatedBy`, `deletedBy` store the `user_id` of the admin who performed the action.
|
||||
@@ -0,0 +1,530 @@
|
||||
"use strict";
|
||||
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: lessons.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Standalone Lesson library — Lessons live independently of Units.
|
||||
*
|
||||
* /admin/lessons → library CRUD (list / create / update / archive / restore / permanent delete)
|
||||
* /admin/lessons/:lessonId/page → the lesson's block content (unchanged contract)
|
||||
*
|
||||
* Membership in a unit is a unit_lessons row (managed from the unit editor /
|
||||
* course builder); archiving here removes the Lesson from every unit at once.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 7, 2026 (junction revamp — Units/Lessons run independently)
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
const { Op } = require("sequelize");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const R = require("../../utils/response.util");
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { recomputeDurations, recomputeUnitDuration, recomputeCourseDuration } = require("../../utils/duration.util");
|
||||
const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util");
|
||||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
||||
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const { nextOrderIndex } = require("../../utils/courses/hierarchy.util");
|
||||
const logActivity = require("../../utils/logActivity.util");
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
Unit, Lesson, LessonPage,
|
||||
CourseUnit, UnitLesson,
|
||||
LessonObjective,
|
||||
} = require("../../models/courses/courses.associations");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||
|
||||
// Refresh every unit this lesson is attached to + the courses above them.
|
||||
async function recomputeParentDurations(lessonId) {
|
||||
const links = await UnitLesson.findAll({ where: { lesson_id: lessonId }, attributes: ["unit_id"] });
|
||||
const unitIds = [...new Set(links.map((l) => String(l.unit_id)))];
|
||||
for (const unitId of unitIds) await recomputeUnitDuration(unitId);
|
||||
if (unitIds.length) {
|
||||
const courseLinks = await CourseUnit.findAll({ where: { unit_id: unitIds }, attributes: ["course_id"] });
|
||||
for (const courseId of new Set(courseLinks.map((l) => String(l.course_id)))) {
|
||||
await recomputeCourseDuration(courseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LESSON_LIST_COMPUTED = [
|
||||
{
|
||||
// Not its own column — consumed by the Title cell on the frontend to
|
||||
// prefix "(UNIT)" when a lesson is already attached to at least one Unit.
|
||||
key: "unit_count",
|
||||
label: "Unit Count",
|
||||
type: "number",
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
literal: `(
|
||||
SELECT CAST(COUNT(*) AS INTEGER)
|
||||
FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = "Lesson"."lesson_id"
|
||||
)`,
|
||||
},
|
||||
{
|
||||
key: "course_count",
|
||||
label: "Affiliated",
|
||||
type: "number",
|
||||
order: 2, // 1: Title, 2: Affiliated, 3: Course Status — see lessons.mdl.js
|
||||
literal: `(
|
||||
SELECT CAST(COUNT(DISTINCT c.course_id) AS INTEGER)
|
||||
FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
JOIN course_units cu ON cu.unit_id = u.unit_id
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = "Lesson"."lesson_id"
|
||||
)`,
|
||||
},
|
||||
{
|
||||
key: "course_status",
|
||||
label: "Course Status",
|
||||
type: "text",
|
||||
order: 3, // 1: Title, 2: Affiliated, 3: Course Status — see lessons.mdl.js
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
literal: `(
|
||||
CASE
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
JOIN course_units cu ON cu.unit_id = u.unit_id
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = "Lesson"."lesson_id"
|
||||
) THEN 'standalone'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
JOIN course_units cu ON cu.unit_id = u.unit_id
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = "Lesson"."lesson_id" AND c.status = 'published'
|
||||
) THEN 'published'
|
||||
ELSE 'draft'
|
||||
END
|
||||
)`,
|
||||
},
|
||||
{
|
||||
// Not its own column — consumed by the frontend's Subscription cell so a
|
||||
// lesson gated only through an affiliated course (own `subscription` is
|
||||
// NULL) still shows a tier instead of "-". Distinct tiers across every
|
||||
// affiliated course, comma-joined (a lesson can reach several courses
|
||||
// through several units, each possibly at a different tier).
|
||||
key: "course_subscription",
|
||||
label: "Course Subscription",
|
||||
type: "text",
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
literal: `(
|
||||
SELECT STRING_AGG(sub.subscription, ', ')
|
||||
FROM (
|
||||
SELECT DISTINCT c.subscription
|
||||
FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
JOIN course_units cu ON cu.unit_id = u.unit_id
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = "Lesson"."lesson_id"
|
||||
) sub
|
||||
)`,
|
||||
},
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// LESSON LIBRARY
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
exports.getLessons = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Lesson, req, {
|
||||
auditOptions: { mdl_Users, parentAlias: "Lesson" },
|
||||
context: "list",
|
||||
computedAttributes: LESSON_LIST_COMPUTED,
|
||||
findOptions: {
|
||||
where: { ...notDeleted },
|
||||
order: [["createdAt", "DESC"]],
|
||||
},
|
||||
});
|
||||
return R.success(res, "Lessons retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Lightweight list for attach pickers
|
||||
exports.getLessonsFlat = async (req, res) => {
|
||||
try {
|
||||
const rows = await sequelize.query(`
|
||||
SELECT
|
||||
l.lesson_id, l.uuid, l.title, l.description, l.duration_seconds,
|
||||
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = l.lesson_id) AS unit_count
|
||||
FROM lessons l
|
||||
WHERE l."deletedAt" IS NULL
|
||||
ORDER BY l.title ASC
|
||||
`, { type: sequelize.QueryTypes.SELECT });
|
||||
return R.success(res, "Lessons retrieved.", rows);
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][GET FLAT]", err);
|
||||
return R.error(res, "Could not retrieve lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Bundle picker (Tier Plan wizard) — mirrors getCoursesBySubscription in
|
||||
// controllers/admin/courses.controller.js.
|
||||
exports.getLessonsBySubscription = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
if (!slug) return R.error(res, 'slug query param is required.', 400);
|
||||
|
||||
const rows = await Lesson.findAll({
|
||||
where: { ...notDeleted, subscription: slug },
|
||||
attributes: ['lesson_id', 'title', 'description', 'subscription'],
|
||||
order: [['title', 'ASC']],
|
||||
});
|
||||
|
||||
// A lesson may belong to any number of other plans (Tier Plans v2, silent
|
||||
// duplication across bundles is intentional) — no conflict to report here.
|
||||
const data = rows.map((l) => l.toJSON());
|
||||
|
||||
return R.success(res, 'Lessons retrieved.', data);
|
||||
} catch (err) {
|
||||
console.error('[LESSON LIB][BY SUBSCRIPTION]', err);
|
||||
return R.error(res, 'Could not retrieve lessons.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getLesson = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
|
||||
const lesson = await Lesson.findOne({
|
||||
where: { lesson_id: lessonId, ...notDeleted },
|
||||
include: [
|
||||
{ model: LessonPage, as: "page", required: false },
|
||||
{ model: LessonObjective, as: "objectives", required: false, order: [["order_index", "ASC"]] },
|
||||
{ model: Unit, as: "units", where: notDeleted, required: false, attributes: ["unit_id", "uuid", "title"], through: { attributes: ["order_index"] } },
|
||||
],
|
||||
});
|
||||
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
return R.success(res, "Lesson retrieved.", { data: lesson.toJSON() });
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][GET ONE]", err);
|
||||
return R.error(res, "Could not retrieve lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.createLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { title, description, subscription, unit_id, order, objectives = [], createdBy } = req.body;
|
||||
if (!title) return R.error(res, "Title is required.", 400);
|
||||
|
||||
const lesson = await Lesson.create({
|
||||
title,
|
||||
subscription: subscription || null,
|
||||
description: description ?? null,
|
||||
duration_seconds: 0,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
}, { transaction: t });
|
||||
|
||||
await LessonPage.create({
|
||||
lesson_id: lesson.lesson_id,
|
||||
blocks: [],
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
}, { transaction: t });
|
||||
|
||||
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t);
|
||||
|
||||
// Optional immediate attach — lets the unit editor create-and-attach in one call
|
||||
if (unit_id) {
|
||||
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, transaction: t });
|
||||
if (!unit) {
|
||||
await t.rollback();
|
||||
return R.error(res, "Unit not found.", 404);
|
||||
}
|
||||
const order_index = order ?? await nextOrderIndex(UnitLesson, { unit_id }, t);
|
||||
await UnitLesson.create({
|
||||
unit_id,
|
||||
lesson_id: lesson.lesson_id,
|
||||
order_index,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
}, { transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user?.user_id, "create_lesson", { entityType: "lesson", entityId: lesson.lesson_id, details: { title: lesson.title, attached_unit_id: unit_id ?? null } });
|
||||
return R.success(res, "Lesson created.", { data: lesson }, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][CREATE]", err);
|
||||
return R.error(res, "Could not create lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
|
||||
const { title, description, subscription, objectives, updatedBy } = req.body;
|
||||
|
||||
if (title !== undefined) lesson.title = title;
|
||||
if (description !== undefined) lesson.description = description;
|
||||
if (subscription !== undefined) lesson.subscription = subscription || null;
|
||||
lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
||||
await lesson.save({ transaction: t });
|
||||
|
||||
if (objectives !== undefined) {
|
||||
await syncObjectivesUpdate(LessonObjective, "lesson_id", lessonId, objectives, t);
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const updated = await Lesson.findOne({
|
||||
where: { lesson_id: lessonId },
|
||||
include: [{ model: LessonObjective, as: "objectives", order: [["order_index", "ASC"]] }],
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, "update_lesson", { entityType: "lesson", entityId: Number(lessonId) });
|
||||
return R.success(res, "Lesson updated.", { data: updated });
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][UPDATE]", err);
|
||||
return R.error(res, "Could not update lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.archiveLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const record = await archiveOne(Lesson, { lesson_id: lessonId, ...notDeleted }, req.user.user_id, t);
|
||||
if (!record) return R.error(res, "Lesson not found.", 404);
|
||||
await t.commit();
|
||||
try { await recomputeParentDurations(lessonId); } catch (durErr) { console.error("[LESSON LIB][ARCHIVE][DURATION]", durErr); }
|
||||
logActivity(req.user.user_id, "archive_lesson", { entityType: "lesson", entityId: Number(lessonId) });
|
||||
return R.success(res, "Lesson archived.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][ARCHIVE]", err);
|
||||
return R.error(res, "Could not archive lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkArchiveLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const lessons = await Lesson.findAll({ where: { lesson_id: ids, ...notDeleted } });
|
||||
const validIds = lessons.map((l) => l.lesson_id);
|
||||
|
||||
const count = await archiveMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
for (const id of validIds) {
|
||||
try { await recomputeParentDurations(id); } catch (durErr) { console.error("[LESSON LIB][BULK ARCHIVE][DURATION]", durErr); }
|
||||
}
|
||||
logActivity(req.user.user_id, "bulk_archive_lessons", { entityType: "lesson", details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} archived.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][BULK ARCHIVE]", err);
|
||||
return R.error(res, "Could not archive lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getArchivedLessons = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Lesson, req, {
|
||||
auditOptions: { mdl_Users, parentAlias: "Lesson" },
|
||||
context: "archived",
|
||||
findOptions: {
|
||||
where: { ...onlyDeleted },
|
||||
paranoid: false,
|
||||
order: [["deletedAt", "DESC"]],
|
||||
},
|
||||
});
|
||||
return R.success(res, "Archived lessons retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][GET ARCHIVES]", err);
|
||||
return R.error(res, "Could not retrieve archived lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getArchivedLesson = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const lesson = await Lesson.findOne({
|
||||
where: { lesson_id: lessonId, ...onlyDeleted },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!lesson) return R.error(res, "Archived lesson not found.", 404);
|
||||
return R.success(res, "Archived lesson retrieved.", { data: lesson.toJSON() });
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][GET ARCHIVE ONE]", err);
|
||||
return R.error(res, "Could not retrieve archived lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.restoreLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const record = await restoreOne(Lesson, { lesson_id: lessonId, ...onlyDeleted }, req.user.user_id, t);
|
||||
if (!record) return R.error(res, "Archived lesson not found.", 404);
|
||||
await t.commit();
|
||||
try { await recomputeParentDurations(lessonId); } catch (durErr) { console.error("[LESSON LIB][RESTORE][DURATION]", durErr); }
|
||||
logActivity(req.user.user_id, "restore_lesson", { entityType: "lesson", entityId: Number(lessonId) });
|
||||
return R.success(res, "Lesson restored.", { data: record });
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][RESTORE]", err);
|
||||
return R.error(res, "Could not restore lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkRestoreLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const lessons = await Lesson.findAll({ where: { lesson_id: ids, ...onlyDeleted }, paranoid: false });
|
||||
const validIds = lessons.map((l) => l.lesson_id);
|
||||
|
||||
const count = await restoreMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
for (const id of validIds) {
|
||||
try { await recomputeParentDurations(id); } catch (durErr) { console.error("[LESSON LIB][BULK RESTORE][DURATION]", durErr); }
|
||||
}
|
||||
logActivity(req.user.user_id, "bulk_restore_lessons", { entityType: "lesson", details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} restored.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][BULK RESTORE]", err);
|
||||
return R.error(res, "Could not restore lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getLessonPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const unitCount = await UnitLesson.count({ where: { lesson_id: lessonId } });
|
||||
return R.success(res, "Impact retrieved.", { unitCount });
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][PERMANENT DELETE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const record = await permanentDeleteOne(Lesson, { lesson_id: lessonId }, t);
|
||||
if (record === null) return R.error(res, "Lesson not found.", 404);
|
||||
if (record === false) return R.error(res, "Lesson must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
await UnitLesson.destroy({ where: { lesson_id: lessonId }, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, "permanently_delete_lesson", { entityType: "lesson", entityId: Number(lessonId) });
|
||||
return R.success(res, "Lesson permanently deleted.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const lessons = await Lesson.findAll({ where: { lesson_id: ids }, paranoid: false });
|
||||
const validIds = lessons.map((l) => l.lesson_id);
|
||||
|
||||
const count = await permanentDeleteMany(Lesson, "lesson_id", validIds, t);
|
||||
await UnitLesson.destroy({ where: { lesson_id: validIds }, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, "bulk_permanently_delete_lessons", { entityType: "lesson", details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} permanently deleted.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// LESSON PAGE (same contract as before — keyed by lessonId only)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
exports.getLessonPage = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const page = await LessonPage.findOne({ where: { lesson_id: lessonId } });
|
||||
if (!page) return R.error(res, "Lesson page not found.", 404);
|
||||
return R.success(res, "Lesson page retrieved.", { data: page });
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][PAGE][GET]", err);
|
||||
return R.error(res, "Could not retrieve lesson page.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.upsertLessonPage = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const { blocks } = req.body;
|
||||
|
||||
if (!Array.isArray(blocks)) return R.error(res, "blocks must be an array.", 400);
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
|
||||
const [page, created] = await LessonPage.upsert({
|
||||
lesson_id: lessonId,
|
||||
blocks,
|
||||
updatedBy: req.body.updatedBy ?? req.user?.user_id ?? null,
|
||||
createdBy: req.body.updatedBy ?? req.user?.user_id ?? null,
|
||||
}, { returning: true });
|
||||
|
||||
try {
|
||||
// Pass the blocks we just wrote directly instead of re-reading the page —
|
||||
// avoids depending on read-after-write visibility of the upsert we just did.
|
||||
await recomputeDurations(lessonId, blocks);
|
||||
} catch (durErr) {
|
||||
console.error("[LESSON LIB][PAGE][DURATION]", durErr);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, "upsert_lesson_page", { entityType: "lesson", entityId: Number(lessonId) });
|
||||
return R.success(
|
||||
res,
|
||||
created ? "Lesson page created." : "Lesson page updated.",
|
||||
{ data: page },
|
||||
created ? 201 : 200,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][PAGE][UPSERT]", err);
|
||||
return R.error(res, "Could not save lesson page.", 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: media.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Issues short-lived JWT stream tokens for admin asset preview.
|
||||
* Works identically to the client media token flow but is scoped to
|
||||
* admin-authenticated requests and allows any asset regardless of
|
||||
* is_public. The stream endpoint (/api/client/media/stream/:token)
|
||||
* is shared — the JWT payload shape is identical.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 22, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
"use strict";
|
||||
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
const R = require("../../utils/response.util");
|
||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
|
||||
// ─── POST /admin/media/token ──────────────────────────────────────────────────
|
||||
|
||||
exports.issueToken = async (req, res) => {
|
||||
try {
|
||||
const { asset_id } = req.body;
|
||||
if (!asset_id) return R.error(res, "asset_id is required.", 400);
|
||||
|
||||
const asset = await mdl_Assets.findOne({
|
||||
where: { asset_id, deletedAt: null },
|
||||
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "thumbnail_storage_key"],
|
||||
});
|
||||
|
||||
if (!asset) return R.error(res, "File not found.", 404);
|
||||
|
||||
if (!mediaToken.SUPPORTED_TYPES.includes(asset.file_type)) {
|
||||
return R.error(res, `File type "${asset.file_type}" is not supported.`, 400);
|
||||
}
|
||||
|
||||
if (asset.storage_provider !== "s3") {
|
||||
return R.error(res, "Token flow is for S3 files only. Use the raw file_url for other providers.", 400);
|
||||
}
|
||||
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
|
||||
|
||||
return R.success(res, "Token issued.", {
|
||||
token,
|
||||
provider: "s3",
|
||||
file_type: asset.file_type,
|
||||
thumbnail_url,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][MEDIA][TOKEN]", err);
|
||||
return R.error(res, "Could not issue media token.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/media/tokens (batch) ───────────────────────────────────────
|
||||
//
|
||||
// Accepts { asset_ids: [id, ...] } — S3 assets only, max 50.
|
||||
// Returns { tokens: { [asset_id]: token } }
|
||||
// One round-trip instead of N per-card requests.
|
||||
|
||||
exports.issueTokensBatch = async (req, res) => {
|
||||
try {
|
||||
const { asset_ids } = req.body;
|
||||
if (!Array.isArray(asset_ids) || !asset_ids.length)
|
||||
return R.error(res, "asset_ids must be a non-empty array.", 400);
|
||||
if (asset_ids.length > 50)
|
||||
return R.error(res, "Maximum 50 asset_ids per batch.", 400);
|
||||
|
||||
const assets = await mdl_Assets.findAll({
|
||||
where: {
|
||||
asset_id: { [Op.in]: asset_ids },
|
||||
storage_provider: "s3",
|
||||
deletedAt: null,
|
||||
},
|
||||
attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"],
|
||||
});
|
||||
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const tokens = {};
|
||||
const thumbnails = {};
|
||||
|
||||
await Promise.all(assets.map(async (asset) => {
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
|
||||
tokens[String(asset.asset_id)] = token;
|
||||
if (thumbnail_url) thumbnails[String(asset.asset_id)] = thumbnail_url;
|
||||
}));
|
||||
|
||||
return R.success(res, "Tokens issued.", { tokens, thumbnails });
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][MEDIA][TOKENS BATCH]", err);
|
||||
return R.error(res, "Could not issue media tokens.", 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : notification.controller.js
|
||||
* Type : Controller (Admin)
|
||||
* Description : Admin notification management.
|
||||
* GET /admin/notifications — paginated list, newest first
|
||||
* GET /admin/notifications/unseen — unseen count only
|
||||
* GET /admin/notifications/sticky — current sticky announcement, if any
|
||||
* PATCH /admin/notifications/:id/seen — mark one as seen
|
||||
* PATCH /admin/notifications/seen-all — mark all as seen
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 19, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const AdminNotification = require('../../models/notifications/admin_notification.mdl');
|
||||
const mdl_Assets = require('../../models/assets/assets.mdl');
|
||||
const mediaToken = require('../../services/mediaToken.service');
|
||||
const R = require('../../utils/response.util');
|
||||
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
|
||||
|
||||
const STICKY_LIMIT = 2;
|
||||
const IMAGE_INCLUDE = {
|
||||
model: mdl_Assets,
|
||||
as: 'image',
|
||||
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
|
||||
required: false,
|
||||
};
|
||||
|
||||
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken.
|
||||
async function attachImageStreamToken(image, req) {
|
||||
if (!image || image.storage_provider !== 's3' || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||
return image;
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||
image.stream_token = token;
|
||||
image.file_url = null;
|
||||
image.thumbnail_url = null;
|
||||
delete image.storage_key;
|
||||
return image;
|
||||
}
|
||||
|
||||
// ─── GET /admin/notifications ─────────────────────────────────────────────────
|
||||
async function list(req, res) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(50, parseInt(req.query.limit) || 20);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { count, rows } = await AdminNotification.findAndCountAll({
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit,
|
||||
offset,
|
||||
where: { show_in_notifications: true, ...notInFutureOrExpired() },
|
||||
});
|
||||
|
||||
return R.success(res, 'Notifications fetched.', {
|
||||
notifications: rows,
|
||||
pagination: { page, limit, total: count, pages: Math.ceil(count / limit) },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION] list error:', err);
|
||||
return R.error(res, 'Failed to fetch notifications.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET /admin/notifications/unseen ─────────────────────────────────────────
|
||||
async function unseenCount(req, res) {
|
||||
try {
|
||||
const count = await AdminNotification.count({ where: { seen: false, show_in_notifications: true, ...notInFutureOrExpired() } });
|
||||
return R.success(res, 'Unseen count fetched.', { count });
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION] unseenCount error:', err);
|
||||
return R.error(res, 'Failed to fetch unseen count.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET /admin/notifications/sticky ──────────────────────────────────────────
|
||||
// Not user-scoped, same as list()/unseenCount() above — one shared sticky
|
||||
// banner for every admin. Whoever dismisses it first dismisses it for all.
|
||||
async function stickyAnnouncement(req, res) {
|
||||
try {
|
||||
const rows = await AdminNotification.findAll({
|
||||
where: {
|
||||
seen: false,
|
||||
show_in_sticky: true,
|
||||
type: 'announcement',
|
||||
...notInFutureOrExpired(),
|
||||
},
|
||||
include: [IMAGE_INCLUDE],
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: STICKY_LIMIT,
|
||||
});
|
||||
|
||||
const notifications = await Promise.all(rows.map(async (row) => {
|
||||
const json = row.toJSON();
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
return json;
|
||||
}));
|
||||
|
||||
return R.success(res, 'Sticky alerts fetched.', { announcements: notifications });
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION] stickyAnnouncement error:', err);
|
||||
return R.error(res, 'Failed to fetch sticky announcement.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PATCH /admin/notifications/:id/seen ─────────────────────────────────────
|
||||
async function markSeen(req, res) {
|
||||
try {
|
||||
const notification = await AdminNotification.findByPk(req.params.id);
|
||||
if (!notification) return R.error(res, 'Notification not found.', 404);
|
||||
|
||||
await notification.update({ seen: true, seen_at: new Date() });
|
||||
return R.success(res, 'Notification marked as seen.', notification);
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION] markSeen error:', err);
|
||||
return R.error(res, 'Failed to mark notification as seen.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PATCH /admin/notifications/seen-all ─────────────────────────────────────
|
||||
async function markAllSeen(req, res) {
|
||||
try {
|
||||
const now = new Date();
|
||||
const [count] = await AdminNotification.update(
|
||||
{ seen: true, seen_at: now },
|
||||
{ where: { seen: false } }
|
||||
);
|
||||
return R.success(res, `${count} notification(s) marked as seen.`, { count });
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION] markAllSeen error:', err);
|
||||
return R.error(res, 'Failed to mark all notifications as seen.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { list, unseenCount, stickyAnnouncement, markSeen, markAllSeen };
|
||||
@@ -0,0 +1,740 @@
|
||||
// controllers/admin/notificationBroadcasts.controller.js
|
||||
|
||||
const sequelize = require("../../config/db.config");
|
||||
const NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl");
|
||||
const AdminNotification = require("../../models/notifications/admin_notification.mdl");
|
||||
const UserNotification = require("../../models/notifications/user_notification.mdl");
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_Assets = require('../../models/assets/assets.mdl');
|
||||
const { TaskList } = require('../../models/task/task.mdl');
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const { Course } = require('../../models/courses/courses.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/notifications/notification_broadcast.attributes");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
|
||||
const {
|
||||
ALLOWED_TARGET_TYPES,
|
||||
SCOPED_TARGET_TYPES,
|
||||
validateTargetId,
|
||||
resolveTaskListUserGroups,
|
||||
resolveTargetUserIds,
|
||||
} = require('../../utils/audienceResolver.util');
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// Fields needed off the associated Asset to render the shared sticky banner
|
||||
// preview AND (for S3 assets) mint a stream token — mirrors advertisements.controller.js.
|
||||
const IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"];
|
||||
const IMAGE_INCLUDE = { model: mdl_Assets, as: "image", attributes: IMAGE_ATTRIBUTES, required: false };
|
||||
|
||||
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken
|
||||
// — kept duplicated rather than shared (same rationale used there).
|
||||
async function attachImageStreamToken(image, req) {
|
||||
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||
return image;
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||
image.stream_token = token;
|
||||
image.file_url = null;
|
||||
image.thumbnail_url = null;
|
||||
delete image.storage_key;
|
||||
return image;
|
||||
}
|
||||
|
||||
// "Active sticky" = live in the rotating sticky banner right now: sent,
|
||||
// show_in_sticky, not archived, and within its own start/end window. Caps the
|
||||
// bar at 3 concurrent slots (see sendBroadcast/updateBroadcast below).
|
||||
async function countActiveSticky(excludeId = null) {
|
||||
return NotificationBroadcast.count({
|
||||
where: {
|
||||
status: 'sent',
|
||||
show_in_sticky: true,
|
||||
...notDeleted,
|
||||
...notInFutureOrExpired(),
|
||||
...(excludeId ? { broadcast_id: { [Op.ne]: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const MAX_ACTIVE_STICKY = 2;
|
||||
const ACTIVE_STICKY_CAP_MESSAGE = `Maximum of ${MAX_ACTIVE_STICKY} active sticky alerts right now — this stays in Draft until one ends or is archived.`;
|
||||
|
||||
async function validateImageAssetId(image_asset_id) {
|
||||
if (!image_asset_id) return null;
|
||||
const asset = await mdl_Assets.findOne({ where: { asset_id: image_asset_id, deletedAt: null } });
|
||||
if (!asset) {
|
||||
const err = new Error("Selected image file was not found.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
return asset.asset_id;
|
||||
}
|
||||
|
||||
// Pushes a visibility change into the already-fanned-out per-recipient rows
|
||||
// (admin_notifications/user_notifications) so archive/restore take effect
|
||||
// immediately for anyone currently seeing the alert — same rationale as the
|
||||
// content/display propagation in updateBroadcast below, just for the two
|
||||
// visibility flags. `where` is a raw SQL fragment + its replacements so this
|
||||
// can target either a single broadcast_id or an IN-list.
|
||||
async function propagateNotificationVisibility(where, { show_in_sticky, show_in_notifications }, transaction) {
|
||||
for (const table of ['admin_notifications', 'user_notifications']) {
|
||||
await sequelize.query(
|
||||
`UPDATE ${table} SET show_in_sticky = :show_in_sticky, show_in_notifications = :show_in_notifications WHERE ${where.sql}`,
|
||||
{ replacements: { show_in_sticky, show_in_notifications, ...where.replacements }, transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBroadcastFields(broadcast, body) {
|
||||
if (body.title !== undefined) broadcast.title = body.title;
|
||||
if (body.message !== undefined) broadcast.message = body.message || null;
|
||||
if (body.link_url !== undefined) broadcast.link_url = body.link_url?.trim() || null;
|
||||
if (body.link_label !== undefined) broadcast.link_label = body.link_label?.trim() || null;
|
||||
if (body.color !== undefined) broadcast.color = body.color || 'indigo';
|
||||
if (body.image_asset_id !== undefined) broadcast.image_asset_id = await validateImageAssetId(body.image_asset_id);
|
||||
|
||||
if (body.start_date !== undefined) broadcast.start_date = body.start_date || null;
|
||||
if (body.end_date !== undefined) broadcast.end_date = body.end_date || null;
|
||||
if (broadcast.start_date && broadcast.end_date && new Date(broadcast.start_date) > new Date(broadcast.end_date)) {
|
||||
const err = new Error("Start date must be before end date.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (body.show_in_sticky !== undefined) broadcast.show_in_sticky = !!body.show_in_sticky;
|
||||
if (body.show_in_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications;
|
||||
|
||||
if (body.target_type !== undefined) {
|
||||
if (!ALLOWED_TARGET_TYPES.includes(body.target_type)) {
|
||||
const err = new Error(`Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (SCOPED_TARGET_TYPES.includes(body.target_type)) {
|
||||
if (!body.target_id) {
|
||||
const err = new Error("target_id is required for this target_type.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
await validateTargetId(body.target_type, body.target_id);
|
||||
broadcast.target_id = String(body.target_id);
|
||||
} else {
|
||||
broadcast.target_id = null;
|
||||
}
|
||||
|
||||
broadcast.target_type = body.target_type;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Target resolution ────────────────────────────────────────────────────────
|
||||
// task_list/course/tier_plan resolution now lives in utils/audienceResolver.util.js
|
||||
// (resolveTargetUserIds, resolveTaskListUserGroups — imported above) so email
|
||||
// broadcasts resolve the same targets identically.
|
||||
|
||||
// Enrich one or many broadcast rows with a human-readable target_label.
|
||||
async function attachTargetLabels(rows) {
|
||||
const list = Array.isArray(rows) ? rows : [rows];
|
||||
const idsByType = { task_list: [], course: [], tier_plan: [] };
|
||||
list.forEach((r) => { if (SCOPED_TARGET_TYPES.includes(r.target_type) && r.target_id) idsByType[r.target_type].push(r.target_id); });
|
||||
|
||||
const [taskLists, courses, plans] = await Promise.all([
|
||||
idsByType.task_list.length ? TaskList.findAll({ where: { task_list_id: { [Op.in]: idsByType.task_list } }, attributes: ['task_list_id', 'name'], paranoid: false }) : [],
|
||||
idsByType.course.length ? Course.findAll({ where: { uuid: { [Op.in]: idsByType.course } }, attributes: ['uuid', 'title'], paranoid: false }) : [],
|
||||
idsByType.tier_plan.length ? mdl_TierPlans.findAll({ where: { plan_id: { [Op.in]: idsByType.tier_plan } }, attributes: ['plan_id', 'label'], paranoid: false }) : [],
|
||||
]);
|
||||
|
||||
const taskListMap = Object.fromEntries(taskLists.map((t) => [t.task_list_id, t.name]));
|
||||
const courseMap = Object.fromEntries(courses.map((c) => [c.uuid, c.title]));
|
||||
const planMap = Object.fromEntries(plans.map((p) => [String(p.plan_id), p.label]));
|
||||
|
||||
list.forEach((r) => {
|
||||
if (r.target_type === 'task_list') r.target_label = taskListMap[r.target_id] ?? null;
|
||||
else if (r.target_type === 'course') r.target_label = courseMap[r.target_id] ?? null;
|
||||
else if (r.target_type === 'tier_plan') r.target_label = planMap[r.target_id] ?? null;
|
||||
else r.target_label = null;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(NotificationBroadcast, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
|
||||
findOptions: { where: { ...notDeleted } },
|
||||
});
|
||||
|
||||
if (Array.isArray(result?.data)) await attachTargetLabels(result.data);
|
||||
|
||||
return R.success(res, "Alerts retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve alerts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({
|
||||
where: { broadcast_id: broadcastId, ...notDeleted },
|
||||
include: [
|
||||
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
||||
IMAGE_INCLUDE,
|
||||
],
|
||||
});
|
||||
|
||||
if (!broadcast) return R.error(res, "Alert not found.", 404);
|
||||
|
||||
const json = broadcast.toJSON();
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
|
||||
if (json.creator) {
|
||||
json.creator = {
|
||||
user_id: json.creator.user_id,
|
||||
full_name: json.creator.personal_info?.name?.full_name ?? null,
|
||||
};
|
||||
}
|
||||
if (json.updater) {
|
||||
json.updater = {
|
||||
user_id: json.updater.user_id,
|
||||
full_name: json.updater.personal_info?.name?.full_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
await attachTargetLabels(json);
|
||||
|
||||
return R.success(res, "Alert retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][GET ONE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.createBroadcast = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
link_url,
|
||||
link_label,
|
||||
color,
|
||||
image_asset_id,
|
||||
target_type,
|
||||
target_id,
|
||||
createdBy,
|
||||
show_in_sticky,
|
||||
show_in_notifications,
|
||||
start_date,
|
||||
end_date,
|
||||
} = req.body;
|
||||
|
||||
if (!title) return R.error(res, "title is required.", 400);
|
||||
if (!target_type) return R.error(res, "target_type is required.", 400);
|
||||
if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`, 400);
|
||||
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, "target_id is required for this target_type.", 400);
|
||||
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
||||
|
||||
const showSticky = show_in_sticky ?? false;
|
||||
const showNotifs = show_in_notifications ?? true;
|
||||
if (!showSticky && !showNotifs) {
|
||||
return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400);
|
||||
}
|
||||
if (showSticky && showNotifs) {
|
||||
return R.error(res, "Choose only one: Sticky or Notifications.", 400);
|
||||
}
|
||||
if (showNotifs && !message) {
|
||||
return R.error(res, "message is required for Notifications alerts.", 400);
|
||||
}
|
||||
|
||||
if (start_date && end_date && new Date(start_date) > new Date(end_date)) {
|
||||
return R.error(res, "Start date must be before end date.", 400);
|
||||
}
|
||||
|
||||
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
|
||||
|
||||
const validatedImageAssetId = await validateImageAssetId(image_asset_id);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const broadcast = await NotificationBroadcast.build({
|
||||
title,
|
||||
message: message || null,
|
||||
link_url: link_url?.trim() || null,
|
||||
link_label: link_label?.trim() || null,
|
||||
color: color || 'indigo',
|
||||
image_asset_id: validatedImageAssetId,
|
||||
start_date: start_date || null,
|
||||
end_date: end_date || null,
|
||||
createdBy,
|
||||
status: 'draft',
|
||||
target_type,
|
||||
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
|
||||
show_in_sticky: showSticky,
|
||||
show_in_notifications: showNotifs,
|
||||
});
|
||||
await broadcast.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'create_notification_broadcast', { entityType: 'notification_broadcast', entityId: broadcast.broadcast_id, details: { target_type } });
|
||||
return R.success(res, "Alert created.", { data: broadcast }, 201);
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* connection gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][CREATE]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||
|
||||
const wasActiveSticky = broadcast.status === 'sent' && broadcast.show_in_sticky;
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await applyBroadcastFields(broadcast, req.body);
|
||||
if (!broadcast.show_in_sticky && !broadcast.show_in_notifications) {
|
||||
const err = new Error("At least one of show_in_sticky or show_in_notifications must be enabled.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (broadcast.show_in_sticky && broadcast.show_in_notifications) {
|
||||
const err = new Error("Choose only one: Sticky or Notifications.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (broadcast.show_in_notifications && !broadcast.message) {
|
||||
const err = new Error("message is required for Notifications alerts.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Editing a live broadcast to newly flip on show_in_sticky is the same
|
||||
// "activate a sticky slot" action as sendBroadcast — must respect the
|
||||
// same 3-slot cap, or it's a trivial bypass.
|
||||
if (broadcast.status === 'sent' && broadcast.show_in_sticky && !wasActiveSticky) {
|
||||
if ((await countActiveSticky(broadcast.broadcast_id)) >= MAX_ACTIVE_STICKY) {
|
||||
const err = new Error(ACTIVE_STICKY_CAP_MESSAGE);
|
||||
err.status = 409;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
broadcast.updatedBy = req.body.updatedBy ?? null;
|
||||
await broadcast.save({ transaction: t });
|
||||
|
||||
// Already-sent broadcasts have per-recipient rows created at send time
|
||||
// (see sendBroadcast) — propagate content/display edits into them so
|
||||
// changes show up immediately for anyone currently seeing it. Target/
|
||||
// audience fields are deliberately NOT propagated (see plan notes):
|
||||
// recipients were already resolved, and task_list's per-user groupId
|
||||
// deep-link (stored in each row's own `data`) must not be clobbered.
|
||||
if (broadcast.status === 'sent') {
|
||||
const propagated = {
|
||||
title: broadcast.title,
|
||||
// admin_notifications/user_notifications.message stays NOT NULL —
|
||||
// sticky-mode broadcasts have a null message here, so fall back to "".
|
||||
message: broadcast.message || "",
|
||||
color: broadcast.color,
|
||||
image_asset_id: broadcast.image_asset_id,
|
||||
show_in_sticky: broadcast.show_in_sticky,
|
||||
show_in_notifications: broadcast.show_in_notifications,
|
||||
start_date: broadcast.start_date,
|
||||
end_date: broadcast.end_date,
|
||||
linkUrl: broadcast.link_url,
|
||||
linkLabel: broadcast.link_label,
|
||||
broadcastId: broadcast.broadcast_id,
|
||||
};
|
||||
|
||||
for (const table of ['admin_notifications', 'user_notifications']) {
|
||||
await sequelize.query(
|
||||
`UPDATE ${table}
|
||||
SET title = :title, message = :message, color = :color, image_asset_id = :image_asset_id,
|
||||
show_in_sticky = :show_in_sticky, show_in_notifications = :show_in_notifications,
|
||||
start_date = :start_date, end_date = :end_date,
|
||||
data = data || jsonb_build_object('linkUrl', :linkUrl, 'linkLabel', :linkLabel)
|
||||
WHERE broadcast_id = :broadcastId`,
|
||||
{ replacements: propagated, transaction: t }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Alert updated.", { data: broadcast });
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][UPDATE]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SEND ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.sendBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||
if (!broadcast) return R.error(res, "Alert not found.", 404);
|
||||
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400);
|
||||
|
||||
if (broadcast.show_in_sticky && (await countActiveSticky(broadcast.broadcast_id)) >= MAX_ACTIVE_STICKY) {
|
||||
return R.error(res, ACTIVE_STICKY_CAP_MESSAGE, 409);
|
||||
}
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const now = new Date();
|
||||
let recipientCount = 0;
|
||||
|
||||
const targetType = broadcast.target_type;
|
||||
const targetId = broadcast.target_id;
|
||||
const showInSticky = !!broadcast.show_in_sticky;
|
||||
const showInNotifications = !!broadcast.show_in_notifications;
|
||||
|
||||
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
|
||||
title: broadcast.title,
|
||||
message: broadcast.message || "",
|
||||
targetType,
|
||||
targetId,
|
||||
linkUrl: broadcast.link_url,
|
||||
linkLabel: broadcast.link_label,
|
||||
});
|
||||
|
||||
if (targetType === 'admin' || targetType === 'both') {
|
||||
await AdminNotification.create(
|
||||
{ ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications, color: broadcast.color, image_asset_id: broadcast.image_asset_id, start_date: broadcast.start_date, end_date: broadcast.end_date, broadcast_id: broadcast.broadcast_id },
|
||||
{ transaction: t }
|
||||
);
|
||||
recipientCount += 1;
|
||||
}
|
||||
|
||||
let userIds = [];
|
||||
let groupByUser = {}; // only populated for task_list — one group_id per user, for deep-linking
|
||||
|
||||
if (targetType === 'user' || targetType === 'both') {
|
||||
const users = await mdl_Users.findAll({
|
||||
attributes: ['user_id'],
|
||||
where: { acc_type: 'user', deletedAt: null },
|
||||
raw: true,
|
||||
transaction: t,
|
||||
});
|
||||
userIds = users.map((u) => String(u.user_id));
|
||||
} else if (targetType === 'task_list') {
|
||||
groupByUser = await resolveTaskListUserGroups(targetId);
|
||||
userIds = Object.keys(groupByUser);
|
||||
} else if (SCOPED_TARGET_TYPES.includes(targetType)) {
|
||||
userIds = await resolveTargetUserIds(targetType, targetId);
|
||||
}
|
||||
|
||||
if (userIds.length) {
|
||||
await UserNotification.bulkCreate(
|
||||
userIds.map((user_id) => ({
|
||||
user_id,
|
||||
...(targetType === 'task_list'
|
||||
? NOTIFICATION_REGISTRY.broadcast.build({
|
||||
title: broadcast.title, message: broadcast.message || "", targetType, targetId,
|
||||
groupId: groupByUser[user_id] ?? null,
|
||||
linkUrl: broadcast.link_url,
|
||||
linkLabel: broadcast.link_label,
|
||||
})
|
||||
: baseNotify),
|
||||
seen: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
show_in_sticky: showInSticky,
|
||||
show_in_notifications: showInNotifications,
|
||||
color: broadcast.color,
|
||||
image_asset_id: broadcast.image_asset_id,
|
||||
start_date: broadcast.start_date,
|
||||
end_date: broadcast.end_date,
|
||||
broadcast_id: broadcast.broadcast_id,
|
||||
})),
|
||||
{ validate: false, transaction: t }
|
||||
);
|
||||
}
|
||||
recipientCount += userIds.length;
|
||||
|
||||
broadcast.status = 'sent';
|
||||
broadcast.sent_at = now;
|
||||
broadcast.recipient_count = recipientCount;
|
||||
await broadcast.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'send_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId), details: { target_type: targetType, target_id: broadcast.target_id, recipient_count: recipientCount } });
|
||||
return R.success(res, "Alert sent.", { data: broadcast });
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][SEND]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||
if (!broadcast) return R.error(res, "Alert not found.", 404);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await broadcast.update({ deletedBy: req.body.deletedBy ?? null }, { transaction: t });
|
||||
await broadcast.destroy({ transaction: t });
|
||||
await propagateNotificationVisibility(
|
||||
{ sql: 'broadcast_id = :broadcastId', replacements: { broadcastId: broadcast.broadcast_id } },
|
||||
{ show_in_sticky: false, show_in_notifications: false },
|
||||
t
|
||||
);
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'archive_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Alert archived.");
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const { ids, deletedBy } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids }, ...notDeleted } });
|
||||
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
|
||||
|
||||
const activeIds = broadcasts.map((b) => b.broadcast_id);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await NotificationBroadcast.update({ deletedBy: deletedBy ?? null }, { where: { broadcast_id: { [Op.in]: activeIds } }, transaction: t });
|
||||
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: activeIds } }, transaction: t });
|
||||
await propagateNotificationVisibility(
|
||||
{ sql: 'broadcast_id IN (:activeIds)', replacements: { activeIds } },
|
||||
{ show_in_sticky: false, show_in_notifications: false },
|
||||
t
|
||||
);
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_archive_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: activeIds, count: activeIds.length } });
|
||||
return R.success(res, `${activeIds.length} notification broadcast(s) archived.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][BULK ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
||||
if (!broadcast) return R.error(res, "Alert not found.", 404);
|
||||
if (!broadcast.deletedAt) return R.error(res, "Alert is not archived.", 400);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await broadcast.restore({ transaction: t });
|
||||
await broadcast.update({ deletedBy: null }, { transaction: t });
|
||||
// Drafts never had per-recipient rows created — only propagate for
|
||||
// broadcasts that were actually sent.
|
||||
if (broadcast.status === 'sent') {
|
||||
await propagateNotificationVisibility(
|
||||
{ sql: 'broadcast_id = :broadcastId', replacements: { broadcastId: broadcast.broadcast_id } },
|
||||
{ show_in_sticky: broadcast.show_in_sticky, show_in_notifications: broadcast.show_in_notifications },
|
||||
t
|
||||
);
|
||||
}
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'restore_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Alert restored.", { data: broadcast });
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
|
||||
|
||||
const archived = broadcasts.filter((b) => b.deletedAt);
|
||||
if (!archived.length) return R.error(res, "All selected notification broadcasts are already active.", 400);
|
||||
|
||||
const archivedIds = archived.map((b) => b.broadcast_id);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await NotificationBroadcast.restore({ where: { broadcast_id: { [Op.in]: archivedIds } }, transaction: t });
|
||||
await NotificationBroadcast.update({ deletedBy: null }, { where: { broadcast_id: { [Op.in]: archivedIds } }, paranoid: false, transaction: t });
|
||||
|
||||
// Visibility can differ per broadcast, so this can't be a single flat
|
||||
// UPDATE like the archive side — loop and restore each one's own
|
||||
// show_in_sticky/show_in_notifications values.
|
||||
for (const b of archived) {
|
||||
if (b.status !== 'sent') continue;
|
||||
await propagateNotificationVisibility(
|
||||
{ sql: 'broadcast_id = :broadcastId', replacements: { broadcastId: b.broadcast_id } },
|
||||
{ show_in_sticky: b.show_in_sticky, show_in_notifications: b.show_in_notifications },
|
||||
t
|
||||
);
|
||||
}
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_restore_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} notification broadcast(s) restored.`, {
|
||||
restored_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][BULK RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(NotificationBroadcast, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
|
||||
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
||||
});
|
||||
return R.success(res, "Archived alerts retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", err);
|
||||
return R.error(res, "Could not retrieve archived alerts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (single) ────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
||||
if (!broadcast) return R.error(res, "Alert not found.", 404);
|
||||
if (!broadcast.deletedAt) return R.error(res, "Alert must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
await broadcast.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Alert permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete alert.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!broadcasts.length) return R.error(res, "No alerts found.", 404);
|
||||
|
||||
const archived = broadcasts.filter((b) => b.deletedAt);
|
||||
if (!archived.length) return R.error(res, "All selected alerts must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archived.map((b) => b.broadcast_id);
|
||||
|
||||
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} alert(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete alerts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// controllers/admin/notificationSettings.controller.js
|
||||
|
||||
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { CRON_PRESETS, CRON_PRESET_BY_EXPRESSION } = require('../../data/cronPresets.data');
|
||||
const { rescheduleJob, getOrCreateSetting } = require('../../cron/cronRegistry.util');
|
||||
|
||||
// ─── Job registry — which cron scope owns each job (for defaults + labels) ────
|
||||
const JOBS = {
|
||||
taskOverdue: { schedule: '0 * * * *', label: 'Task Alerts (Admin)', description: 'Automatically marks expired tasks as overdue or completed, and notifies admins.' },
|
||||
userNotifications: { schedule: '5 * * * *', label: 'Task Alerts (Users)', description: 'Notifies affected users when their tasks are automatically marked overdue or completed.' },
|
||||
issueCertificates: { schedule: '0 * * * *', label: 'Certificate Issued', description: 'Notifies users when a course certificate is ready.' },
|
||||
expireUserTiers: { schedule: '* * * * *', label: 'Tier Expired', description: 'Notifies users when their subscription tier expires.' },
|
||||
};
|
||||
|
||||
// Jobs whose behavior can be tuned via target_status, and the values each accepts.
|
||||
const TARGET_STATUS_OPTIONS = ['overdue', 'completed'];
|
||||
const TARGET_STATUS_JOBS = ['taskOverdue'];
|
||||
|
||||
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getSettings = async (req, res) => {
|
||||
try {
|
||||
const rows = [];
|
||||
for (const [job_name, meta] of Object.entries(JOBS)) {
|
||||
const row = await getOrCreateSetting(job_name, meta.schedule);
|
||||
rows.push({
|
||||
job_name,
|
||||
enabled: row.enabled,
|
||||
schedule: row.schedule,
|
||||
preset: CRON_PRESET_BY_EXPRESSION[row.schedule] ?? null,
|
||||
target_status: TARGET_STATUS_JOBS.includes(job_name)
|
||||
? (row.target_status ?? 'completed')
|
||||
: null,
|
||||
label: meta.label,
|
||||
description: meta.description,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return R.success(res, 'Notification settings retrieved.', rows);
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION SETTINGS][GET]', err);
|
||||
return R.error(res, 'Could not retrieve notification settings.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateSetting = async (req, res) => {
|
||||
try {
|
||||
const { jobName } = req.params;
|
||||
const { enabled, preset, target_status, updatedBy } = req.body;
|
||||
|
||||
if (!JOBS[jobName]) return R.error(res, `Unknown job "${jobName}".`, 404);
|
||||
|
||||
const row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
|
||||
if (!row) return R.error(res, 'Setting not found.', 404);
|
||||
|
||||
if (enabled !== undefined) row.enabled = enabled === true || enabled === 'true';
|
||||
|
||||
if (target_status !== undefined) {
|
||||
if (!TARGET_STATUS_JOBS.includes(jobName)) {
|
||||
return R.error(res, `"target_status" is not configurable for job "${jobName}".`, 400);
|
||||
}
|
||||
if (!TARGET_STATUS_OPTIONS.includes(target_status)) {
|
||||
return R.error(res, `Invalid target_status. Must be one of: ${TARGET_STATUS_OPTIONS.join(', ')}`, 400);
|
||||
}
|
||||
row.target_status = target_status;
|
||||
}
|
||||
|
||||
if (preset !== undefined) {
|
||||
const schedule = CRON_PRESETS[preset];
|
||||
if (!schedule) return R.error(res, `Invalid preset. Must be one of: ${Object.keys(CRON_PRESETS).join(', ')}`, 400);
|
||||
row.schedule = schedule;
|
||||
|
||||
try {
|
||||
rescheduleJob(jobName, schedule);
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION SETTINGS][RESCHEDULE]', err);
|
||||
return R.error(res, `Saved, but failed to reschedule the live job: ${err.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
row.updatedBy = updatedBy ?? null;
|
||||
await row.save();
|
||||
|
||||
logActivity(req.user?.user_id, 'update_notification_setting', { entityType: 'cron_notification_setting', entityId: jobName, details: { enabled: row.enabled, schedule: row.schedule, target_status: row.target_status } });
|
||||
return R.success(res, 'Notification setting updated.', { data: row });
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION SETTINGS][UPDATE]', err);
|
||||
return R.error(res, 'Internal server error.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
const mdl_Product = require('../../models/courses/products.mdl');
|
||||
const mdl_Category = require('../../models/courses/categories.mdl');
|
||||
const { Course, CourseProductCategory: mdl_CourseProductCategory } = require('../../models/courses/courses.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
// ─── PRODUCT (generic, keyed by purchasable_type + purchasable_id) ───────────
|
||||
// Course/Unit/Lesson each get their own thin route + exported handler below,
|
||||
// all delegating to these so the CRUD logic isn't tripled across the three
|
||||
// content types — see routes/admin/products.routes.js.
|
||||
|
||||
async function getProductFor(purchasable_type, purchasable_id) {
|
||||
return mdl_Product.findOne({ where: { purchasable_type, purchasable_id }, paranoid: false });
|
||||
}
|
||||
|
||||
async function upsertProductFor(purchasable_type, purchasable_id, body, adminUserId) {
|
||||
const { name, description, price, currency, access_days, is_active } = body;
|
||||
if (!name || price == null) {
|
||||
const err = new Error('name and price are required.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const existing = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id }, paranoid: false });
|
||||
|
||||
if (existing) {
|
||||
if (existing.deletedAt) await existing.restore();
|
||||
await existing.update({ name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
|
||||
logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: existing.id, details: { purchasable_type, purchasable_id, name } });
|
||||
return { product: existing, created: false };
|
||||
}
|
||||
|
||||
const product = await mdl_Product.create({ purchasable_type, purchasable_id, name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
|
||||
logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: product.id, details: { purchasable_type, purchasable_id, name } });
|
||||
return { product, created: true };
|
||||
}
|
||||
|
||||
async function removeProductFor(purchasable_type, purchasable_id, adminUserId) {
|
||||
const product = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id } });
|
||||
if (!product) return false;
|
||||
await product.destroy();
|
||||
logActivity(adminUserId, `remove_${purchasable_type}_product`, { entityType: 'product', details: { purchasable_type, purchasable_id } });
|
||||
return true;
|
||||
}
|
||||
|
||||
function makeProductHandlers(purchasable_type, paramName) {
|
||||
return {
|
||||
get: async (req, res) => {
|
||||
try {
|
||||
const product = await getProductFor(purchasable_type, req.params[paramName]);
|
||||
return R.success(res, 'Product retrieved.', product ?? null);
|
||||
} catch (err) {
|
||||
console.error(`[ADMIN][PRODUCTS][GET][${purchasable_type}]`, err);
|
||||
return R.error(res, 'Could not retrieve product.', 500);
|
||||
}
|
||||
},
|
||||
upsert: async (req, res) => {
|
||||
try {
|
||||
const { product, created } = await upsertProductFor(purchasable_type, req.params[paramName], req.body, req.user?.user_id);
|
||||
return R.success(res, created ? 'Product created.' : 'Product updated.', product, created ? 201 : 200);
|
||||
} catch (err) {
|
||||
if (err.status === 400) return R.error(res, err.message, 400);
|
||||
console.error(`[ADMIN][PRODUCTS][UPSERT][${purchasable_type}]`, err);
|
||||
return R.error(res, 'Could not save product.', 500);
|
||||
}
|
||||
},
|
||||
remove: async (req, res) => {
|
||||
try {
|
||||
const removed = await removeProductFor(purchasable_type, req.params[paramName], req.user?.user_id);
|
||||
if (!removed) return R.error(res, 'Product not found.', 404);
|
||||
return R.success(res, 'Product removed.');
|
||||
} catch (err) {
|
||||
console.error(`[ADMIN][PRODUCTS][REMOVE][${purchasable_type}]`, err);
|
||||
return R.error(res, 'Could not remove product.', 500);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const courseProductHandlers = makeProductHandlers('course', 'courseId');
|
||||
|
||||
exports.getCourseProduct = courseProductHandlers.get;
|
||||
exports.upsertCourseProduct = courseProductHandlers.upsert;
|
||||
exports.removeCourseProduct = courseProductHandlers.remove;
|
||||
|
||||
// ─── CATEGORIES (per course) ──────────────────────────────────────────────────
|
||||
|
||||
exports.getCourseCategories = async (req, res) => {
|
||||
try {
|
||||
const course = await Course.findByPk(req.params.courseId, {
|
||||
include: [{ model: mdl_Category, as: 'categories', through: { attributes: [] } }],
|
||||
});
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
return R.success(res, 'Course categories retrieved.', course.categories ?? []);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PRODUCTS][GET CATEGORIES]', err);
|
||||
return R.error(res, 'Could not retrieve course categories.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.syncCourseCategories = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const { category_ids = [] } = req.body;
|
||||
|
||||
await mdl_CourseProductCategory.destroy({ where: { course_id: courseId } });
|
||||
|
||||
if (category_ids.length > 0) {
|
||||
await mdl_CourseProductCategory.bulkCreate(
|
||||
category_ids.map((id) => ({ course_id: courseId, category_id: id }))
|
||||
);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'sync_course_categories', { entityType: 'product', details: { course_id: courseId, category_ids, count: category_ids.length } });
|
||||
return R.success(res, 'Course categories updated.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PRODUCTS][SYNC CATEGORIES]', err);
|
||||
return R.error(res, 'Could not sync course categories.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: profile.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Self-service profile management for admin users.
|
||||
* All routes require: authenticate → requireAdmin()
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/admin/profile → view own profile
|
||||
* PUT /api/admin/profile → update personal_info
|
||||
* POST /api/admin/profile/avatar → upload / replace avatar
|
||||
* DELETE /api/admin/profile/avatar → remove avatar
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 18, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service');
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
// ─── GET own profile ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.getProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
return R.success(res, 'Profile retrieved.', await resolveUserAvatar(user));
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve profile.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT update own profile ────────────────────────────────────────────────────
|
||||
|
||||
exports.updateProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
const { personal_info } = req.body;
|
||||
|
||||
const merged = {
|
||||
...(user.personal_info || {}),
|
||||
...(personal_info || {}),
|
||||
name: {
|
||||
...((user.personal_info?.name) || {}),
|
||||
...((personal_info?.name) || {}),
|
||||
},
|
||||
};
|
||||
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
return R.success(res, 'Profile updated.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN] updateProfile error:', err);
|
||||
return R.error(res, 'Profile update failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST upload own avatar ────────────────────────────────────────────────────
|
||||
|
||||
exports.uploadAvatar = async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return R.error(res, 'No file provided.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
|
||||
const avatarMeta = await replaceUserAvatar(user, req.file);
|
||||
|
||||
const merged = { ...(user.personal_info || {}), avatar: avatarMeta };
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
if (err.status === 400) return R.error(res, err.message, 400);
|
||||
console.error('[ADMIN] uploadAvatar error:', err);
|
||||
return R.error(res, 'Avatar upload failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE remove own avatar ──────────────────────────────────────────────────
|
||||
|
||||
exports.deleteAvatar = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
|
||||
await removeUserAvatar(user);
|
||||
|
||||
const merged = { ...(user.personal_info || {}), avatar: null };
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
return R.success(res, 'Avatar removed.');
|
||||
} catch (err) {
|
||||
if (err.status === 404) return R.error(res, err.message, 404);
|
||||
console.error('[ADMIN] deleteAvatar error:', err);
|
||||
return R.error(res, 'Could not remove avatar.', 500);
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,347 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: task_completion.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin-level task completion management.
|
||||
* Admins can view all completions per task, view a single completion,
|
||||
* and archive/restore completions. Completions are created by clients only.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 13, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op, Sequelize } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
|
||||
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
|
||||
const { Task } = require('../../models/task/task.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { checkTaskCompletion, fireTaskCompletedEvent } = require('../client/task.controller');
|
||||
|
||||
const { adminExclude } = require('../../models/task/task_completion.attributes');
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const { archiveOne, archiveMany } = require('../../utils/courses/archive.util');
|
||||
const { restoreOne, restoreMany } = require('../../utils/courses/restore.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
|
||||
const COMPLETION_FIELDS = ['submitted_at', 'createdAt', 'updatedAt', 'deletedAt'];
|
||||
|
||||
// ─── Reusable include: completion files ───────────────────────────────────────
|
||||
// separate: true → Sequelize fetches files in a second SELECT ... WHERE completion_id IN (...)
|
||||
// instead of a JOIN, which avoids the subquery alias conflict that occurs when
|
||||
// paginate applies LIMIT/OFFSET alongside a hasMany include.
|
||||
const FILES_INCLUDE = {
|
||||
model: TaskCompletionFile,
|
||||
as: 'files',
|
||||
attributes: { exclude: adminExclude },
|
||||
paranoid: false,
|
||||
separate: true,
|
||||
order: [['createdAt', 'ASC']],
|
||||
};
|
||||
|
||||
// ─── Reusable include: submitting user ────────────────────────────────────────
|
||||
const USER_INCLUDE = {
|
||||
model: mdl_Users,
|
||||
as: 'user',
|
||||
attributes: [
|
||||
'user_id',
|
||||
'email', // ← direct column, fine as-is
|
||||
[
|
||||
Sequelize.literal(`("user"."personal_info"->'name'->>'full_name')`),
|
||||
'name',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── COMPLETIONS (nested under task-list → task) ───────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions
|
||||
|
||||
exports.getCompletions = async (req, res) => {
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
|
||||
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
|
||||
if (!task) return R.error(res, 'Task not found.', 404);
|
||||
|
||||
const result = await paginate(TaskCompletion, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas: {},
|
||||
computedAttributes: [],
|
||||
auditOptions: { mdl_Users, parentAlias: 'TaskCompletion' },
|
||||
allowedFields: COMPLETION_FIELDS,
|
||||
findOptions: {
|
||||
where: { task_id: taskId },
|
||||
include: [USER_INCLUDE, FILES_INCLUDE],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Completions retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ALL COMPLETIONS]', err);
|
||||
return R.error(res, 'Could not retrieve completions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId
|
||||
|
||||
exports.getCompletion = async (req, res) => {
|
||||
try {
|
||||
const { taskListId, taskId, completionId } = req.params;
|
||||
|
||||
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
|
||||
if (!task) return R.error(res, 'Task not found.', 404);
|
||||
|
||||
const completion = await TaskCompletion.findOne({
|
||||
where: { completion_id: completionId, task_id: taskId },
|
||||
attributes: { exclude: adminExclude },
|
||||
paranoid: false,
|
||||
include: [USER_INCLUDE, FILES_INCLUDE],
|
||||
});
|
||||
|
||||
if (!completion) return R.error(res, 'Completion not found.', 404);
|
||||
|
||||
return R.success(res, 'Completion retrieved.', completion);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET COMPLETION]', err);
|
||||
return R.error(res, 'Could not retrieve completion.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ALL BY USER ──────────────────────────────────────────────────────────
|
||||
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId
|
||||
|
||||
exports.getCompletionsByUser = async (req, res) => {
|
||||
try {
|
||||
const { taskListId, taskId, userId } = req.params;
|
||||
|
||||
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
|
||||
if (!task) return R.error(res, 'Task not found.', 404);
|
||||
|
||||
const result = await paginate(TaskCompletion, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas: {},
|
||||
computedAttributes: [],
|
||||
auditOptions: { mdl_Users, parentAlias: 'TaskCompletion' },
|
||||
allowedFields: COMPLETION_FIELDS,
|
||||
findOptions: {
|
||||
where: { task_id: taskId, user_id: userId },
|
||||
include: [FILES_INCLUDE],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'User completions retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET COMPLETIONS BY USER]', err);
|
||||
return R.error(res, 'Could not retrieve user completions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── REVIEW ───────────────────────────────────────────────────────────────────
|
||||
// PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/review
|
||||
// Approves/rejects a submission for a requirement flagged requires_review.
|
||||
// Notifies the submitting learner via the existing template pattern (same shape
|
||||
// as updateTask's task_requirements_updated notify block).
|
||||
|
||||
exports.reviewSubmission = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId, completionId } = req.params;
|
||||
const { status, review_note } = req.body;
|
||||
|
||||
if (!['approved', 'rejected'].includes(status)) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'status must be "approved" or "rejected".', 400);
|
||||
}
|
||||
|
||||
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
|
||||
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
|
||||
const completion = await TaskCompletion.findOne({
|
||||
where: { completion_id: completionId, task_id: taskId },
|
||||
transaction: t,
|
||||
});
|
||||
if (!completion) { await t.rollback(); return R.error(res, 'Completion not found.', 404); }
|
||||
|
||||
const wasComplete = status === 'approved' ? await checkTaskCompletion(completion.user_id, taskId) : false;
|
||||
|
||||
await completion.update({
|
||||
status,
|
||||
review_note: review_note || null,
|
||||
reviewed_by: req.user.user_id,
|
||||
reviewed_at: new Date(),
|
||||
updatedBy: req.user.user_id,
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
if (status === 'approved' && !wasComplete && await checkTaskCompletion(completion.user_id, taskId)) {
|
||||
fireTaskCompletedEvent(completion.user_id, taskId); // fire-and-forget
|
||||
}
|
||||
|
||||
logActivity(req.user.user_id, 'review_task_submission', {
|
||||
entityType: 'task_completion', entityId: completionId, details: { task_id: taskId, status },
|
||||
});
|
||||
|
||||
try {
|
||||
const notify = NOTIFICATION_REGISTRY.task_submission_reviewed.build({
|
||||
taskName: task.name, status, review_note: review_note || null,
|
||||
});
|
||||
await UserNotification.create({ user_id: completion.user_id, ...notify, seen: false });
|
||||
} catch (notifyErr) {
|
||||
console.error('[ADMIN][REVIEW SUBMISSION][NOTIFY]', notifyErr);
|
||||
}
|
||||
|
||||
return R.success(res, 'Submission reviewed.', completion);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][REVIEW SUBMISSION]', err);
|
||||
return R.error(res, 'Could not review submission.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE ──────────────────────────────────────────────────────────────────
|
||||
// DELETE /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId
|
||||
|
||||
exports.archiveCompletion = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId, completionId } = req.params;
|
||||
|
||||
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
|
||||
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
|
||||
const record = await archiveOne(
|
||||
TaskCompletion,
|
||||
{ completion_id: completionId, task_id: taskId },
|
||||
req.user.user_id,
|
||||
t
|
||||
);
|
||||
if (!record) { await t.rollback(); return R.error(res, 'Completion not found.', 404); }
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'archive_completion', { entityType: 'task_completion', entityId: Number(completionId), details: { task_id: taskId } });
|
||||
return R.success(res, 'Completion archived successfully.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][ARCHIVE COMPLETION]', err);
|
||||
return R.error(res, 'Could not archive completion.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────────
|
||||
// PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore
|
||||
|
||||
exports.restoreCompletion = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId, completionId } = req.params;
|
||||
|
||||
const record = await restoreOne(
|
||||
TaskCompletion,
|
||||
{ completion_id: completionId, task_id: taskId, deletedAt: { [Op.not]: null } },
|
||||
req.user.user_id,
|
||||
t
|
||||
);
|
||||
if (!record) { await t.rollback(); return R.error(res, 'Completion not found or not archived.', 404); }
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'restore_completion', { entityType: 'task_completion', entityId: Number(completionId), details: { task_id: taskId } });
|
||||
return R.success(res, 'Completion restored successfully.', record);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][RESTORE COMPLETION]', err);
|
||||
return R.error(res, 'Could not restore completion.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK ARCHIVE ─────────────────────────────────────────────────────────────
|
||||
// POST /admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive
|
||||
|
||||
exports.bulkArchiveCompletions = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No completion IDs provided.', 400);
|
||||
|
||||
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
|
||||
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
|
||||
const completions = await TaskCompletion.findAll({
|
||||
where: { completion_id: ids, task_id: taskId },
|
||||
});
|
||||
if (!completions.length) return R.error(res, 'No completions found.', 404);
|
||||
|
||||
const activeIds = completions
|
||||
.filter((c) => !c.deletedAt)
|
||||
.map((c) => c.completion_id);
|
||||
|
||||
if (!activeIds.length)
|
||||
return R.error(res, 'All selected completions are already archived.', 400);
|
||||
|
||||
const count = await archiveMany(TaskCompletion, 'completion_id', activeIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_archive_completions', { entityType: 'task_completion', details: { ids: activeIds, count, task_id: taskId } });
|
||||
return R.success(res, `${count} completion(s) archived successfully.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][BULK ARCHIVE COMPLETIONS]', err);
|
||||
return R.error(res, 'Could not archive completions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
|
||||
// POST /admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore
|
||||
|
||||
exports.bulkRestoreCompletions = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No completion IDs provided.', 400);
|
||||
|
||||
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
|
||||
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
|
||||
const completions = await TaskCompletion.findAll({
|
||||
where: { completion_id: ids, task_id: taskId },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!completions.length) return R.error(res, 'No completions found.', 404);
|
||||
|
||||
const deletedIds = completions
|
||||
.filter((c) => c.deletedAt)
|
||||
.map((c) => c.completion_id);
|
||||
|
||||
if (!deletedIds.length)
|
||||
return R.error(res, 'All selected completions are already active.', 400);
|
||||
|
||||
const count = await restoreMany(TaskCompletion, 'completion_id', deletedIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_restore_completions', { entityType: 'task_completion', details: { ids: deletedIds, count, task_id: taskId } });
|
||||
return R.success(res, `${count} completion(s) restored successfully.`, {
|
||||
restored_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][BULK RESTORE COMPLETIONS]', err);
|
||||
return R.error(res, 'Could not restore completions.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const Asset = require('../../models/assets/assets.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
require('../../models/tiers/tier.associations');
|
||||
|
||||
const ASSET_ATTRS = ['asset_id', 'file_url', 'display_name', 'mime_type'];
|
||||
|
||||
const withBadge = [{ model: Asset, as: 'badgeAsset', attributes: ASSET_ATTRS, required: false }];
|
||||
|
||||
// ─── GET /admin/tiers/categories ─────────────────────────────────────────────
|
||||
|
||||
exports.getCategories = async (req, res) => {
|
||||
try {
|
||||
const categories = await mdl_TierCategories.findAll({
|
||||
include: withBadge,
|
||||
order: [['rank', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Subscription categories retrieved.', categories);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET TIER CATEGORIES]', err);
|
||||
return R.error(res, 'Could not retrieve subscription categories.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/tiers/categories/:id ─────────────────────────────────────────
|
||||
|
||||
exports.getCategory = async (req, res) => {
|
||||
try {
|
||||
const cat = await mdl_TierCategories.findByPk(req.params.id, { include: withBadge });
|
||||
if (!cat) return R.error(res, 'Subscription category not found.', 404);
|
||||
return R.success(res, 'Subscription category retrieved.', cat);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET TIER CATEGORY]', err);
|
||||
return R.error(res, 'Could not retrieve subscription category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/tiers/categories ────────────────────────────────────────────
|
||||
|
||||
exports.createCategory = async (req, res) => {
|
||||
try {
|
||||
const { slug, name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_special } = req.body;
|
||||
if (!slug || !name) return R.error(res, 'slug and name are required.', 400);
|
||||
|
||||
const parsedRank = Number(rank ?? 1);
|
||||
if (parsedRank <= 0) return R.error(res, 'Non-default subscription categories must have rank greater than 0.', 400);
|
||||
|
||||
const exists = await mdl_TierCategories.findOne({ where: { slug } });
|
||||
if (exists) return R.error(res, `A subscription category with slug "${slug}" already exists.`, 409);
|
||||
|
||||
const cat = await mdl_TierCategories.create({
|
||||
slug, name,
|
||||
description: description ?? null,
|
||||
rank: parsedRank,
|
||||
color: color || 'purple',
|
||||
badge_asset_id: badge_asset_id || null,
|
||||
badge_icon: badge_icon || null,
|
||||
badge_label: badge_label ?? null,
|
||||
is_default: false,
|
||||
is_active: true,
|
||||
is_special: !!is_special,
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'create_tier_category', { entityType: 'tier_category', details: { slug, name } });
|
||||
|
||||
const result = await mdl_TierCategories.findByPk(cat.tier_category_id, { include: withBadge });
|
||||
return R.success(res, 'Subscription category created.', result, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE TIER CATEGORY]', err);
|
||||
return R.error(res, 'Could not create subscription category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT /admin/tiers/categories/:id ─────────────────────────────────────────
|
||||
|
||||
exports.updateCategory = async (req, res) => {
|
||||
try {
|
||||
const cat = await mdl_TierCategories.findByPk(req.params.id);
|
||||
if (!cat) return R.error(res, 'Subscription category not found.', 404);
|
||||
|
||||
const { name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_active, is_special } = req.body;
|
||||
|
||||
if (!cat.is_default && rank !== undefined) {
|
||||
const parsedRank = Number(rank);
|
||||
if (parsedRank <= 0) return R.error(res, 'Non-default subscription categories must have rank greater than 0.', 400);
|
||||
}
|
||||
|
||||
await cat.update({
|
||||
name: name ?? cat.name,
|
||||
description: description !== undefined ? (description || null) : cat.description,
|
||||
rank: rank !== undefined ? Number(rank) : cat.rank,
|
||||
color: color !== undefined ? (color || cat.color) : cat.color,
|
||||
badge_asset_id: badge_asset_id !== undefined ? (badge_asset_id || null) : cat.badge_asset_id,
|
||||
badge_icon: badge_icon !== undefined ? (badge_icon || null) : cat.badge_icon,
|
||||
badge_label: badge_label !== undefined ? (badge_label || null) : cat.badge_label,
|
||||
// Default category (free) cannot be deactivated
|
||||
is_active: (!cat.is_default && is_active !== undefined) ? is_active : cat.is_active,
|
||||
is_special: is_special !== undefined ? !!is_special : cat.is_special,
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'update_tier_category', { entityType: 'tier_category', details: { id: cat.tier_category_id, slug: cat.slug } });
|
||||
|
||||
const result = await mdl_TierCategories.findByPk(cat.tier_category_id, { include: withBadge });
|
||||
return R.success(res, 'Subscription category updated.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE TIER CATEGORY]', err);
|
||||
return R.error(res, 'Could not update subscription category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE /admin/tiers/categories/:id ──────────────────────────────────────
|
||||
|
||||
exports.deleteCategory = async (req, res) => {
|
||||
try {
|
||||
const cat = await mdl_TierCategories.findByPk(req.params.id);
|
||||
if (!cat) return R.error(res, 'Subscription category not found.', 404);
|
||||
if (cat.is_default) return R.error(res, 'The default (Free) subscription category cannot be deleted.', 400);
|
||||
|
||||
// Block deletion if active plans still reference this category
|
||||
const activePlans = await mdl_TierPlans.count({
|
||||
where: { tier_category_id: cat.tier_category_id, is_active: true },
|
||||
});
|
||||
if (activePlans > 0)
|
||||
return R.error(res, `Cannot delete — ${activePlans} active plan(s) belong to this category. Archive or reassign them first.`, 409);
|
||||
|
||||
await cat.destroy();
|
||||
logActivity(req.user?.user_id, 'delete_tier_category', { entityType: 'tier_category', details: { slug: cat.slug } });
|
||||
return R.success(res, 'Subscription category deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DELETE TIER CATEGORY]', err);
|
||||
return R.error(res, 'Could not delete subscription category.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl');
|
||||
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
|
||||
const Asset = require('../../models/assets/assets.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
require('../../models/tiers/tier.associations');
|
||||
|
||||
const ASSET_ATTRS = ['asset_id', 'file_url', 'display_name', 'mime_type'];
|
||||
|
||||
// ─── PAYMENT POLICIES ─────────────────────────────────────────────────────────
|
||||
|
||||
const VALID_PROMO_TYPES = new Set(['flat', 'percent']);
|
||||
const VALID_WINDOW_UNITS = new Set(['minutes', 'hours', 'days']);
|
||||
|
||||
function validatePromoRules(rules) {
|
||||
if (!Array.isArray(rules)) return 'promo_rules must be an array.';
|
||||
for (const r of rules) {
|
||||
if (!r.code || typeof r.code !== 'string') return 'Each promo rule must have a code string.';
|
||||
if (!VALID_PROMO_TYPES.has(r.type)) return `Invalid promo type "${r.type}". Must be 'flat' or 'percent'.`;
|
||||
if (!r.value || Number(r.value) <= 0) return 'Promo rule value must be a positive number.';
|
||||
if (r.max_uses != null && (!Number.isInteger(r.max_uses) || r.max_uses < 1))
|
||||
return 'max_uses must be a positive integer.';
|
||||
if (r.expires_at != null && isNaN(new Date(r.expires_at).getTime()))
|
||||
return 'expires_at must be a valid ISO date string.';
|
||||
if (r.min_amount != null && Number(r.min_amount) < 0)
|
||||
return 'min_amount must be a non-negative number.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateRefundPolicy(rp) {
|
||||
if (typeof rp !== 'object' || rp === null || Array.isArray(rp))
|
||||
return 'refund_policy must be an object.';
|
||||
if (rp.allowed != null && typeof rp.allowed !== 'boolean')
|
||||
return 'refund_policy.allowed must be a boolean.';
|
||||
if (rp.window_unit != null && !VALID_WINDOW_UNITS.has(rp.window_unit))
|
||||
return `refund_policy.window_unit must be 'minutes', 'hours', or 'days'.`;
|
||||
if (rp.window_value != null && (typeof rp.window_value !== 'number' || rp.window_value <= 0))
|
||||
return 'refund_policy.window_value must be a positive number.';
|
||||
return null;
|
||||
}
|
||||
|
||||
exports.getPaymentPolicy = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.planId);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const policy = await mdl_PaymentPolicies.findOne({ where: { plan_id: req.params.planId } });
|
||||
return R.success(res, 'Payment policy retrieved.', policy ?? null);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PAYMENT POLICY]', err);
|
||||
return R.error(res, 'Could not retrieve payment policy.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.upsertPaymentPolicy = async (req, res) => {
|
||||
try {
|
||||
const { planId } = req.params;
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(planId);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const { promo_rules, refund_policy, allowed_providers } = req.body;
|
||||
|
||||
if (promo_rules !== undefined) {
|
||||
const err = validatePromoRules(promo_rules);
|
||||
if (err) return R.error(res, err, 400);
|
||||
}
|
||||
|
||||
if (refund_policy !== undefined) {
|
||||
const err = validateRefundPolicy(refund_policy);
|
||||
if (err) return R.error(res, err, 400);
|
||||
}
|
||||
|
||||
if (allowed_providers !== undefined) {
|
||||
if (!Array.isArray(allowed_providers) || !allowed_providers.every((p) => typeof p === 'string'))
|
||||
return R.error(res, 'allowed_providers must be an array of provider name strings.', 400);
|
||||
}
|
||||
|
||||
let existing = await mdl_PaymentPolicies.findOne({ where: { plan_id: planId } });
|
||||
|
||||
const DEFAULTS = { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false };
|
||||
|
||||
const payload = {
|
||||
plan_id: planId,
|
||||
promo_rules: promo_rules !== undefined ? promo_rules : (existing?.promo_rules ?? []),
|
||||
refund_policy: refund_policy !== undefined ? refund_policy : (existing?.refund_policy ?? DEFAULTS),
|
||||
allowed_providers: allowed_providers !== undefined ? allowed_providers : (existing?.allowed_providers ?? ['paypal']),
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
existing = await mdl_PaymentPolicies.create(payload);
|
||||
logActivity(req.user?.user_id, 'create_payment_policy', { entityType: 'payment_policy', details: { plan_id: planId } });
|
||||
} else {
|
||||
await existing.update(payload);
|
||||
logActivity(req.user?.user_id, 'update_payment_policy', { entityType: 'payment_policy', details: { plan_id: planId } });
|
||||
}
|
||||
|
||||
const result = await mdl_PaymentPolicies.findOne({ where: { plan_id: planId } });
|
||||
return R.success(res, 'Payment policy saved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPSERT PAYMENT POLICY]', err);
|
||||
return R.error(res, 'Could not save payment policy.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SYSTEM BADGES ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getSystemBadges = async (req, res) => {
|
||||
try {
|
||||
const badges = await mdl_SystemBadges.findAll({
|
||||
include: [{ model: Asset, as: 'asset', attributes: ASSET_ATTRS, required: false }],
|
||||
order: [['key', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'System badges retrieved.', badges);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET SYSTEM BADGES]', err);
|
||||
return R.error(res, 'Could not retrieve system badges.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getSystemBadge = async (req, res) => {
|
||||
try {
|
||||
const badge = await mdl_SystemBadges.findOne({
|
||||
where: { key: req.params.key },
|
||||
include: [{ model: Asset, as: 'asset', attributes: ASSET_ATTRS, required: false }],
|
||||
});
|
||||
if (!badge) return R.error(res, 'System badge not found.', 404);
|
||||
return R.success(res, 'System badge retrieved.', badge);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET SYSTEM BADGE]', err);
|
||||
return R.error(res, 'Could not retrieve system badge.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.upsertSystemBadge = async (req, res) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const { asset_id, label, description, information, active_from, active_until } = req.body;
|
||||
|
||||
let existing = await mdl_SystemBadges.findOne({ where: { key } });
|
||||
|
||||
const payload = {
|
||||
key,
|
||||
asset_id: asset_id !== undefined ? (asset_id || null) : existing?.asset_id ?? null,
|
||||
label: label ?? existing?.label ?? key,
|
||||
description: description ?? existing?.description ?? null,
|
||||
information: information ?? existing?.information ?? null,
|
||||
active_from: active_from !== undefined ? (active_from || null) : existing?.active_from ?? null,
|
||||
active_until: active_until !== undefined ? (active_until || null) : existing?.active_until ?? null,
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
existing = await mdl_SystemBadges.create(payload);
|
||||
logActivity(req.user?.user_id, 'create_system_badge', { entityType: 'system_badge', details: { key } });
|
||||
} else {
|
||||
await existing.update(payload);
|
||||
logActivity(req.user?.user_id, 'update_system_badge', { entityType: 'system_badge', details: { key } });
|
||||
}
|
||||
|
||||
const result = await mdl_SystemBadges.findOne({
|
||||
where: { key },
|
||||
include: [{ model: Asset, as: 'asset', attributes: ASSET_ATTRS, required: false }],
|
||||
});
|
||||
return R.success(res, 'System badge saved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPSERT SYSTEM BADGE]', err);
|
||||
return R.error(res, 'Could not save system badge.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,714 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: tiers.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin-level tier and plan management.
|
||||
* - CRUD + archive/restore for tier_plans
|
||||
* - View/grant/revoke user tiers
|
||||
* - Paginated payments list
|
||||
* Author: rgrgogu
|
||||
* Date Created: Jun. 6, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op, ForeignKeyConstraintError } = require('sequelize');
|
||||
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl');
|
||||
const mdl_PlanUnits = require('../../models/tiers/plan_units.mdl');
|
||||
const mdl_PlanLessons = require('../../models/tiers/plan_lessons.mdl');
|
||||
const { Course } = require('../../models/courses/courses.mdl');
|
||||
const Unit = require('../../models/courses/units.mdl');
|
||||
const Lesson = require('../../models/courses/lessons.mdl');
|
||||
|
||||
require('../../models/tiers/tier.associations');
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const { getFieldValues } = require('../../utils/fieldValues.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { snapshotPlanGrants } = require('../../services/tierGrants.service');
|
||||
const { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk } = require('../../services/planAccess.service');
|
||||
|
||||
const {
|
||||
excludeAttributes: plansExclude,
|
||||
jsonbSchemas: plansSchemas,
|
||||
computedAttributes: plansComputed,
|
||||
} = require('../../models/tiers/tier_plans.attributes');
|
||||
|
||||
const {
|
||||
excludeAttributes: paymentsExclude,
|
||||
jsonbSchemas: paymentsSchemas,
|
||||
computedAttributes: paymentsComputed,
|
||||
} = require('../../models/tiers/payments.attributes');
|
||||
|
||||
const cc = require('currency-codes');
|
||||
|
||||
const PENDING_PAYMENT_EXPIRY_MINUTES = 60;
|
||||
|
||||
// ─── CURRENCIES ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getCurrencies = (req, res) => {
|
||||
const list = cc.codes().map((code) => {
|
||||
const entry = cc.code(code);
|
||||
return { code: entry.code, name: entry.currency };
|
||||
}).sort((a, b) => a.code.localeCompare(b.code));
|
||||
return R.success(res, 'OK', list);
|
||||
};
|
||||
|
||||
const expireStalePendingPayments = async () => {
|
||||
const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000);
|
||||
await mdl_Payments.update(
|
||||
{ status: 'expired' },
|
||||
{ where: { status: 'pending', createdAt: { [Op.lt]: expiresBefore } } }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// ─── PLANS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getPlans = async (req, res) => {
|
||||
try {
|
||||
const archived = req.query.archived === 'true';
|
||||
|
||||
const result = await paginate(mdl_TierPlans, req, {
|
||||
excludeAttributes: plansExclude,
|
||||
jsonbSchemas: plansSchemas,
|
||||
computedAttributes: plansComputed,
|
||||
context: archived ? 'archived' : 'list',
|
||||
auditOptions: { mdl_Users, parentAlias: 'TierPlan' },
|
||||
findOptions: archived ? {
|
||||
paranoid: false,
|
||||
where: { deletedAt: { [Op.ne]: null } },
|
||||
} : {},
|
||||
});
|
||||
|
||||
return R.success(res, 'Plans retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLANS]', err);
|
||||
return R.error(res, 'Could not retrieve plans.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getPlan = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
return R.success(res, 'Plan retrieved.', plan);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN]', err);
|
||||
return R.error(res, 'Could not retrieve plan.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
const DURATION_UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
|
||||
|
||||
function computeDurationDays(value, unit) {
|
||||
const multiplier = DURATION_UNIT_TO_DAYS[unit] ?? 1;
|
||||
return parseFloat(value) * multiplier;
|
||||
}
|
||||
|
||||
exports.createPlan = async (req, res) => {
|
||||
try {
|
||||
const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency, createdBy, status } = req.body;
|
||||
if (!tier_category_id || !label || !duration_value || !price)
|
||||
return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400);
|
||||
|
||||
const category = await mdl_TierCategories.findByPk(tier_category_id);
|
||||
if (!category || !category.is_active)
|
||||
return R.error(res, 'Subscription category not found or inactive.', 404);
|
||||
|
||||
if (category.is_default)
|
||||
return R.error(res, 'Plans cannot be created under the default (Free) subscription. Free access is automatic.', 400);
|
||||
|
||||
const duration_days = computeDurationDays(duration_value, duration_unit);
|
||||
|
||||
const plan = await mdl_TierPlans.create({
|
||||
tier_category_id: category.tier_category_id,
|
||||
tier: category.slug,
|
||||
label, description, features, duration_days, duration_unit, price, currency,
|
||||
status: status ?? 'draft',
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
});
|
||||
const plain = plan.get({ plain: true });
|
||||
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
|
||||
return R.success(res, 'Plan created.', { ...plain, plan_id: String(plain.plan_id) }, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE PLAN]', err);
|
||||
return R.error(res, 'Could not create plan.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updatePlan = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const allowed = ['label', 'description', 'features', 'price', 'currency', 'is_active', 'is_recommended', 'status', 'tier_category_id'];
|
||||
const updates = {};
|
||||
for (const k of allowed) {
|
||||
if (req.body[k] !== undefined) updates[k] = req.body[k];
|
||||
}
|
||||
updates.updatedBy = req.body.updatedBy ?? req.user?.user_id ?? null;
|
||||
|
||||
// Recompute duration_days when value or unit changes
|
||||
const { duration_value, duration_unit } = req.body;
|
||||
if (duration_value !== undefined) {
|
||||
const unit = duration_unit ?? plan.duration_unit ?? 'day';
|
||||
updates.duration_days = computeDurationDays(duration_value, unit);
|
||||
updates.duration_unit = unit;
|
||||
} else if (duration_unit !== undefined) {
|
||||
updates.duration_unit = duration_unit;
|
||||
}
|
||||
|
||||
// If tier_category_id is being changed, sync the tier slug
|
||||
if (updates.tier_category_id) {
|
||||
const category = await mdl_TierCategories.findByPk(updates.tier_category_id);
|
||||
if (!category || !category.is_active)
|
||||
return R.error(res, 'Subscription category not found or inactive.', 404);
|
||||
if (category.is_default)
|
||||
return R.error(res, 'Plans cannot be moved to the Free subscription category.', 400);
|
||||
updates.tier = category.slug;
|
||||
}
|
||||
|
||||
await plan.update(updates);
|
||||
logActivity(req.user?.user_id, 'update_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
|
||||
return R.success(res, 'Plan updated.', plan);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE PLAN]', err);
|
||||
return R.error(res, 'Could not update plan.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getPlanImpact = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const active_subscriber_count = await mdl_UserTiers.count({
|
||||
where: { plan_id: req.params.id, status: 'active' },
|
||||
});
|
||||
|
||||
return R.success(res, 'Plan impact retrieved.', { active_subscriber_count });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN IMPACT]', err);
|
||||
return R.error(res, 'Could not retrieve plan impact.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.archivePlan = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
if (plan.deletedAt) return R.error(res, 'Plan is already archived.', 400);
|
||||
|
||||
await plan.update({ is_active: false, deletedBy: req.user?.user_id ?? null });
|
||||
await plan.destroy();
|
||||
|
||||
// Archiving always force-revokes current subscribers' access (no refund) —
|
||||
// fires tier_plan_access_revoked (see revokePlanSubscriberAccess), not the
|
||||
// old "access unaffected" tier_plan_archived notice, since that's no
|
||||
// longer true.
|
||||
let revoked_user_count = 0;
|
||||
try {
|
||||
({ revoked_user_count } = await revokePlanSubscriberAccess(plan, req.user?.user_id ?? null));
|
||||
} catch (revokeErr) {
|
||||
console.error('[ADMIN][ARCHIVE PLAN][REVOKE ACCESS]', revokeErr);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label, revoked_user_count } });
|
||||
return R.success(res, 'Plan archived successfully.', { revoked_user_count });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][ARCHIVE PLAN]', err);
|
||||
return R.error(res, 'Could not archive plan.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkArchivePlans = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No plan IDs provided.', 400);
|
||||
|
||||
const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids } });
|
||||
if (!plans.length) return R.error(res, 'No plans found.', 404);
|
||||
|
||||
const activePlans = plans.filter((p) => !p.deletedAt);
|
||||
if (!activePlans.length)
|
||||
return R.error(res, 'All selected plans are already archived.', 400);
|
||||
|
||||
const activeIds = activePlans.map((p) => p.plan_id);
|
||||
|
||||
await mdl_TierPlans.update({ is_active: false, deletedBy: req.user?.user_id ?? null }, { where: { plan_id: activeIds } });
|
||||
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
|
||||
|
||||
// Archiving always force-revokes current subscribers' access (no refund) —
|
||||
// one batched call across all selected plans (each still fires its own
|
||||
// tier_plan_access_revoked with its own label) instead of one revoke call
|
||||
// per plan, so this stays O(1) DB round trips regardless of selection size.
|
||||
let revoked_user_count = 0;
|
||||
try {
|
||||
({ revoked_user_count } = await revokePlanSubscriberAccessBulk(activePlans, req.user?.user_id ?? null));
|
||||
} catch (revokeErr) {
|
||||
console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length, revoked_user_count } });
|
||||
return R.success(res, `${activeIds.length} plan(s) archived successfully.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
revoked_user_count,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK ARCHIVE PLANS]', err);
|
||||
return R.error(res, 'Could not archive plans.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.restorePlan = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findOne({
|
||||
where: { plan_id: req.params.id }, paranoid: false,
|
||||
});
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
if (!plan.deletedAt) return R.error(res, 'Plan is not archived.', 400);
|
||||
|
||||
await plan.restore();
|
||||
await plan.update({ is_active: true });
|
||||
logActivity(req.user?.user_id, 'restore_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
|
||||
return R.success(res, 'Plan restored successfully.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][RESTORE PLAN]', err);
|
||||
return R.error(res, 'Could not restore plan.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkRestorePlans = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No plan IDs provided.', 400);
|
||||
|
||||
const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids }, paranoid: false });
|
||||
if (!plans.length) return R.error(res, 'No plans found.', 404);
|
||||
|
||||
const archivedPlans = plans.filter((p) => p.deletedAt);
|
||||
if (!archivedPlans.length)
|
||||
return R.error(res, 'All selected plans are already active.', 400);
|
||||
|
||||
const archivedIds = archivedPlans.map((p) => p.plan_id);
|
||||
|
||||
await mdl_TierPlans.restore({ where: { plan_id: archivedIds } });
|
||||
await mdl_TierPlans.update({ is_active: true }, { where: { plan_id: archivedIds }, paranoid: false });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_restore_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} plan(s) restored successfully.`, {
|
||||
restored_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK RESTORE PLANS]', err);
|
||||
return R.error(res, 'Could not restore plans.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getPlanPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findOne({ where: { plan_id: req.params.id }, paranoid: false });
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const active_subscriber_count = await mdl_UserTiers.count({
|
||||
where: { plan_id: req.params.id, status: 'active' },
|
||||
});
|
||||
const payment_count = await mdl_Payments.count({ where: { plan_id: req.params.id } });
|
||||
|
||||
return R.success(res, 'Plan permanent-delete impact retrieved.', { active_subscriber_count, payment_count });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN PERMANENT DELETE IMPACT]', err);
|
||||
return R.error(res, 'Could not retrieve plan permanent-delete impact.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeletePlan = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findOne({
|
||||
where: { plan_id: req.params.id }, paranoid: false,
|
||||
});
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
if (!plan.deletedAt) return R.error(res, 'Plan must be archived before it can be permanently deleted.', 400);
|
||||
|
||||
// A plan may still have active subscribers if it was archived before the
|
||||
// auto-revoke-on-archive behavior existed, or if revoking failed the
|
||||
// first time — permanently deleting it must not leave them with orphaned
|
||||
// access (user_tiers.plan_id would just go NULL on delete, not revoke).
|
||||
const { revoked_user_count } = await revokePlanSubscriberAccess(plan, req.user?.user_id ?? null);
|
||||
|
||||
// payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted
|
||||
// by default) — a plan can't be force-destroyed while payment rows still
|
||||
// reference it, so those rows are force-destroyed first. This permanently
|
||||
// erases that plan's payment/billing history; there is no undo.
|
||||
const deleted_payment_count = await mdl_Payments.destroy({ where: { plan_id: plan.plan_id }, force: true });
|
||||
|
||||
await plan.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label, deleted_payment_count, revoked_user_count } });
|
||||
return R.success(res, 'Plan permanently deleted.', { deleted_payment_count, revoked_user_count });
|
||||
} catch (err) {
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
return R.error(res, 'Cannot delete: this plan still has records on file referencing it.', 400);
|
||||
}
|
||||
console.error('[ADMIN][PERMANENT DELETE PLAN]', err);
|
||||
return R.error(res, 'Could not permanently delete plan.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeletePlans = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No plan IDs provided.', 400);
|
||||
|
||||
const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids }, paranoid: false });
|
||||
if (!plans.length) return R.error(res, 'No plans found.', 404);
|
||||
|
||||
const archivedPlans = plans.filter((p) => p.deletedAt);
|
||||
if (!archivedPlans.length)
|
||||
return R.error(res, 'All selected plans must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const archivedIds = archivedPlans.map((p) => p.plan_id);
|
||||
|
||||
// Same reasoning as the single-delete path above: revoke any remaining
|
||||
// active subscribers (each plan still gets its own label on the
|
||||
// notification/email) before the records are gone for good — batched in
|
||||
// one call across all selected plans instead of one call per plan.
|
||||
const { revoked_user_count } = await revokePlanSubscriberAccessBulk(archivedPlans, req.user?.user_id ?? null);
|
||||
|
||||
// payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted
|
||||
// by default) — plans can't be force-destroyed while payment rows still
|
||||
// reference them, so those rows are force-destroyed first. This permanently
|
||||
// erases these plans' payment/billing history; there is no undo.
|
||||
const deleted_payment_count = await mdl_Payments.destroy({ where: { plan_id: archivedIds }, force: true });
|
||||
|
||||
await mdl_TierPlans.destroy({ where: { plan_id: archivedIds }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length, deleted_payment_count, revoked_user_count } });
|
||||
return R.success(res, `${archivedIds.length} plan(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
deleted_payment_count,
|
||||
revoked_user_count,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
return R.error(res, 'Cannot delete: one or more selected plans still have records on file referencing them.', 400);
|
||||
}
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE PLANS]', err);
|
||||
return R.error(res, 'Could not permanently delete plans.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getPlanFieldValues = getFieldValues(mdl_TierPlans, 'TIER_PLAN', {
|
||||
blockedFields: [],
|
||||
});
|
||||
|
||||
// ─── USER TIERS ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getUserTiers = async (req, res) => {
|
||||
try {
|
||||
const tiers = await mdl_UserTiers.findAll({
|
||||
where: { user_id: req.params.id },
|
||||
include: [
|
||||
{ model: mdl_Users, as: 'grantedByUser', attributes: ['user_id', 'email'] },
|
||||
{ model: mdl_Users, as: 'revokedByUser', attributes: ['user_id', 'email'] },
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return R.success(res, 'User subscriptions retrieved.', tiers);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USER TIERS]', err);
|
||||
return R.error(res, 'Could not retrieve user subscriptions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.grantTier = async (req, res) => {
|
||||
try {
|
||||
const { user_id, plan_id, notes } = req.body;
|
||||
if (!user_id || !plan_id)
|
||||
return R.error(res, 'user_id and plan_id are required.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(user_id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(plan_id);
|
||||
if (!plan || !plan.is_active) return R.error(res, 'Plan not found or inactive.', 404);
|
||||
const tier = plan.tier;
|
||||
|
||||
// Keyed on plan_id, not tier — a user may already hold a different plan
|
||||
// at this same tier slug (Tier Plans v2 allows multiple concurrently
|
||||
// active plans per tier); only re-granting the exact same plan is blocked.
|
||||
const existingActive = await mdl_UserTiers.findOne({
|
||||
where: { user_id, plan_id, status: 'active' },
|
||||
});
|
||||
if (existingActive) {
|
||||
return R.error(res, `User already has this plan active until ${existingActive.expires_at}.`, 409);
|
||||
}
|
||||
|
||||
const startsAt = new Date();
|
||||
const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000);
|
||||
|
||||
const newTier = await mdl_UserTiers.create({
|
||||
user_id, tier, plan_id, status: 'active',
|
||||
starts_at: startsAt,
|
||||
expires_at: expiresAt,
|
||||
granted_by: req.user.user_id,
|
||||
notes,
|
||||
});
|
||||
|
||||
await snapshotPlanGrants(newTier, plan_id);
|
||||
|
||||
logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } });
|
||||
return R.success(res, 'Subscription granted.', newTier, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GRANT TIER]', err);
|
||||
return R.error(res, 'Could not grant subscription.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.revokeTier = async (req, res) => {
|
||||
try {
|
||||
const tierRecord = await mdl_UserTiers.findByPk(req.params.tid);
|
||||
if (!tierRecord) return R.error(res, 'Subscription record not found.', 404);
|
||||
if (tierRecord.status !== 'active') return R.error(res, 'Subscription is not active.', 400);
|
||||
|
||||
await tierRecord.update({
|
||||
status: 'revoked',
|
||||
revoked_by: req.user.user_id,
|
||||
revoked_at: new Date(),
|
||||
});
|
||||
|
||||
// Only fall back to free if the user has no other concurrently active tier —
|
||||
// revoking one subscription shouldn't drop them below a tier they still hold.
|
||||
const remainingActive = await mdl_UserTiers.count({
|
||||
where: { user_id: tierRecord.user_id, status: 'active' },
|
||||
});
|
||||
|
||||
if (remainingActive === 0) {
|
||||
await mdl_UserTiers.create({
|
||||
user_id: tierRecord.user_id,
|
||||
tier: 'free',
|
||||
status: 'active',
|
||||
starts_at: new Date(),
|
||||
expires_at: null,
|
||||
granted_by: req.user.user_id,
|
||||
notes: 'Auto-downgrade after revoke.',
|
||||
});
|
||||
}
|
||||
|
||||
logActivity(req.user.user_id, 'revoke_tier', { entityType: 'tier', details: { user_id: tierRecord.user_id, tier: tierRecord.tier } });
|
||||
return R.success(res, remainingActive === 0 ? 'Subscription revoked. User downgraded to free.' : 'Subscription revoked.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][REVOKE TIER]', err);
|
||||
return R.error(res, 'Could not revoke subscription.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PAYMENTS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getPayments = async (req, res) => {
|
||||
try {
|
||||
await expireStalePendingPayments();
|
||||
|
||||
const result = await paginate(mdl_Payments, req, {
|
||||
excludeAttributes: paymentsExclude,
|
||||
jsonbSchemas: paymentsSchemas,
|
||||
computedAttributes: paymentsComputed,
|
||||
context: 'list',
|
||||
findOptions: {
|
||||
include: [
|
||||
{ model: mdl_Users, as: 'user', attributes: ['user_id', 'email'] },
|
||||
{ model: mdl_TierPlans, as: 'plan', attributes: ['plan_id', 'label', 'tier', 'duration_days'] },
|
||||
],
|
||||
},
|
||||
});
|
||||
return R.success(res, 'Payments retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PAYMENTS]', err);
|
||||
return R.error(res, 'Could not retrieve payments.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getPayment = async (req, res) => {
|
||||
try {
|
||||
await expireStalePendingPayments();
|
||||
|
||||
const payment = await mdl_Payments.findByPk(req.params.id, {
|
||||
include: [
|
||||
{ model: mdl_Users, as: 'user', attributes: ['user_id', 'email'] },
|
||||
{ model: mdl_TierPlans, as: 'plan' },
|
||||
{ model: mdl_UserTiers, as: 'tier' },
|
||||
],
|
||||
});
|
||||
if (!payment) return R.error(res, 'Payment not found.', 404);
|
||||
return R.success(res, 'Payment retrieved.', payment);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PAYMENT]', err);
|
||||
return R.error(res, 'Could not retrieve payment.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getPaymentFieldValues = getFieldValues(mdl_Payments, 'PAYMENT', {
|
||||
blockedFields: ['provider_payload'],
|
||||
});
|
||||
|
||||
|
||||
// ─── PLAN COURSES ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getPlanCourses = async (req, res) => {
|
||||
try {
|
||||
const entries = await mdl_PlanCourses.findAll({
|
||||
where: { plan_id: req.params.id },
|
||||
include: [{
|
||||
model: Course,
|
||||
as: 'course',
|
||||
attributes: ['course_id', 'title', 'course_code', 'subscription', 'level'],
|
||||
}],
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Plan courses retrieved.', entries.map(e => e.course));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN COURSES]', err);
|
||||
return R.error(res, 'Could not retrieve plan courses.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.syncPlanCourses = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { course_ids = [] } = req.body;
|
||||
|
||||
if (!Array.isArray(course_ids))
|
||||
return R.error(res, 'course_ids must be an array.', 400);
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
// Remove all courses from this plan
|
||||
await mdl_PlanCourses.destroy({ where: { plan_id: id } });
|
||||
|
||||
if (course_ids.length) {
|
||||
// A course may already belong to other plans — that's allowed (Tier Plans v2,
|
||||
// silent duplication across bundles), so we only ever touch this plan's own rows.
|
||||
await mdl_PlanCourses.bulkCreate(
|
||||
course_ids.map(course_id => ({ plan_id: id, course_id })),
|
||||
);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'sync_plan_courses', { entityType: 'tier_plan', details: { plan_id: id, course_ids, count: course_ids.length } });
|
||||
return R.success(res, 'Plan courses updated.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][SYNC PLAN COURSES]', err);
|
||||
return R.error(res, 'Could not update plan courses.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PLAN UNITS ───────────────────────────────────────────────────────────────
|
||||
// Mirrors PLAN COURSES above — bundling/display only, not an access-control
|
||||
// mechanism (see canAccessUnit in controllers/client/courses.controller.js).
|
||||
|
||||
exports.getPlanUnits = async (req, res) => {
|
||||
try {
|
||||
const entries = await mdl_PlanUnits.findAll({
|
||||
where: { plan_id: req.params.id },
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: 'unit',
|
||||
attributes: ['unit_id', 'title', 'subscription'],
|
||||
}],
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Plan units retrieved.', entries.map(e => e.unit));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN UNITS]', err);
|
||||
return R.error(res, 'Could not retrieve plan units.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.syncPlanUnits = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { unit_ids = [] } = req.body;
|
||||
|
||||
if (!Array.isArray(unit_ids))
|
||||
return R.error(res, 'unit_ids must be an array.', 400);
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
await mdl_PlanUnits.destroy({ where: { plan_id: id } });
|
||||
|
||||
if (unit_ids.length) {
|
||||
// A unit may already belong to other plans — that's allowed (Tier Plans v2,
|
||||
// silent duplication across bundles), so we only ever touch this plan's own rows.
|
||||
await mdl_PlanUnits.bulkCreate(
|
||||
unit_ids.map(unit_id => ({ plan_id: id, unit_id })),
|
||||
);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'sync_plan_units', { entityType: 'tier_plan', details: { plan_id: id, unit_ids, count: unit_ids.length } });
|
||||
return R.success(res, 'Plan units updated.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][SYNC PLAN UNITS]', err);
|
||||
return R.error(res, 'Could not update plan units.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PLAN LESSONS ─────────────────────────────────────────────────────────────
|
||||
// Mirrors PLAN COURSES above — bundling/display only, not an access-control
|
||||
// mechanism (see canAccessLesson in controllers/client/courses.controller.js).
|
||||
|
||||
exports.getPlanLessons = async (req, res) => {
|
||||
try {
|
||||
const entries = await mdl_PlanLessons.findAll({
|
||||
where: { plan_id: req.params.id },
|
||||
include: [{
|
||||
model: Lesson,
|
||||
as: 'lesson',
|
||||
attributes: ['lesson_id', 'title', 'subscription'],
|
||||
}],
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Plan lessons retrieved.', entries.map(e => e.lesson));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN LESSONS]', err);
|
||||
return R.error(res, 'Could not retrieve plan lessons.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.syncPlanLessons = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { lesson_ids = [] } = req.body;
|
||||
|
||||
if (!Array.isArray(lesson_ids))
|
||||
return R.error(res, 'lesson_ids must be an array.', 400);
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
await mdl_PlanLessons.destroy({ where: { plan_id: id } });
|
||||
|
||||
if (lesson_ids.length) {
|
||||
// A lesson may already belong to other plans — that's allowed (Tier Plans v2,
|
||||
// silent duplication across bundles), so we only ever touch this plan's own rows.
|
||||
await mdl_PlanLessons.bulkCreate(
|
||||
lesson_ids.map(lesson_id => ({ plan_id: id, lesson_id })),
|
||||
);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'sync_plan_lessons', { entityType: 'tier_plan', details: { plan_id: id, lesson_ids, count: lesson_ids.length } });
|
||||
return R.success(res, 'Plan lessons updated.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][SYNC PLAN LESSONS]', err);
|
||||
return R.error(res, 'Could not update plan lessons.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,816 @@
|
||||
"use strict";
|
||||
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: units.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Standalone Unit library — Units live independently of Courses.
|
||||
*
|
||||
* /admin/units → library CRUD (list / create / update / archive / restore / permanent delete)
|
||||
* /admin/units/:unitId/lessons → attach / detach / reorder standalone Lessons on this Unit
|
||||
* /admin/units/:unitId/quiz → the Unit's quiz (travels with the Unit into every course it's attached to)
|
||||
*
|
||||
* Membership in a course is a course_units row (managed from the course builder);
|
||||
* archiving here removes the Unit from every course view at once.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 7, 2026 (junction revamp — Units/Lessons run independently)
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
const { Op } = require("sequelize");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const R = require("../../utils/response.util");
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
||||
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const { flattenLessons, nextOrderIndex, reorderJunction } = require("../../utils/courses/hierarchy.util");
|
||||
const { recomputeUnitDuration, recomputeCourseDuration } = require("../../utils/duration.util");
|
||||
const { syncObjectivesCreate } = require("../../utils/courses/objectives.util");
|
||||
const logActivity = require("../../utils/logActivity.util");
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
Course, Unit, Lesson, LessonPage,
|
||||
CourseUnit, UnitLesson, LessonObjective,
|
||||
UnitQuiz, QuizQuestion, QuizOption,
|
||||
UnitReadingProgress, LessonReadingProgress,
|
||||
} = require("../../models/courses/courses.associations");
|
||||
const CompletionRequirement = require("../../models/courses/completion_requirement.mdl");
|
||||
const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||
|
||||
// Refresh duration of every course this unit is attached to (post-archive/restore).
|
||||
async function recomputeParentCourseDurations(unitId) {
|
||||
const links = await CourseUnit.findAll({ where: { unit_id: unitId }, attributes: ["course_id"] });
|
||||
for (const courseId of new Set(links.map((l) => String(l.course_id)))) {
|
||||
await recomputeCourseDuration(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
const UNIT_LIST_COMPUTED = [
|
||||
{
|
||||
key: "quiz_id",
|
||||
label: "Quiz ID",
|
||||
type: "text",
|
||||
literal: `(SELECT quiz_id FROM unit_quizzes WHERE unit_id = "Unit"."unit_id" AND "deletedAt" IS NULL LIMIT 1)`,
|
||||
},
|
||||
{
|
||||
key: "lesson_count",
|
||||
label: "Lessons",
|
||||
type: "number",
|
||||
literal: `(
|
||||
SELECT CAST(COUNT(*) AS INTEGER)
|
||||
FROM unit_lessons ul
|
||||
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
|
||||
WHERE ul.unit_id = "Unit"."unit_id"
|
||||
)`,
|
||||
},
|
||||
{
|
||||
// Not its own column — consumed by the Title cell on the frontend to
|
||||
// show a "Course" badge when a unit is already attached to at least one Course.
|
||||
key: "course_count",
|
||||
label: "Affiliated",
|
||||
type: "number",
|
||||
order: 2, // 1: Title, 2: Affiliated, 3: Subscription, 4: Duration — see units.mdl.js
|
||||
hidden: true,
|
||||
literal: `(
|
||||
SELECT CAST(COUNT(*) AS INTEGER)
|
||||
FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = "Unit"."unit_id"
|
||||
)`,
|
||||
},
|
||||
{
|
||||
key: "course_status",
|
||||
label: "Course Status",
|
||||
type: "text",
|
||||
order: 3, // 1: Title, 2: Affiliated, 3: Course Status, 4: Subscription, 5: Duration — see units.mdl.js
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
literal: `(
|
||||
CASE
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = "Unit"."unit_id"
|
||||
) THEN 'standalone'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = "Unit"."unit_id" AND c.status = 'published'
|
||||
) THEN 'published'
|
||||
ELSE 'draft'
|
||||
END
|
||||
)`,
|
||||
},
|
||||
{
|
||||
// Not its own column — consumed by the frontend's Subscription cell so a
|
||||
// unit gated only through an affiliated course (own `subscription` is
|
||||
// NULL) still shows a tier instead of "-". Distinct tiers across every
|
||||
// affiliated course, comma-joined (a unit can sit in several courses at
|
||||
// different tiers).
|
||||
key: "course_subscription",
|
||||
label: "Course Subscription",
|
||||
type: "text",
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
literal: `(
|
||||
SELECT STRING_AGG(sub.subscription, ', ')
|
||||
FROM (
|
||||
SELECT DISTINCT c.subscription
|
||||
FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = "Unit"."unit_id"
|
||||
) sub
|
||||
)`,
|
||||
},
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// UNIT LIBRARY
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
exports.getUnits = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Unit, req, {
|
||||
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
||||
context: "list",
|
||||
computedAttributes: UNIT_LIST_COMPUTED,
|
||||
findOptions: {
|
||||
where: { ...notDeleted },
|
||||
order: [["createdAt", "DESC"]],
|
||||
},
|
||||
});
|
||||
return R.success(res, "Units retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Lightweight list for attach pickers: { unit_id, uuid, title, lesson_count, course_count, course_title }
|
||||
exports.getUnitsFlat = async (req, res) => {
|
||||
try {
|
||||
const rows = await sequelize.query(`
|
||||
SELECT
|
||||
u.unit_id, u.uuid, u.title, u.description, u.duration_seconds,
|
||||
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
|
||||
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
|
||||
WHERE ul.unit_id = u.unit_id) AS lesson_count,
|
||||
(SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = u.unit_id) AS course_count,
|
||||
(SELECT c.title FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = u.unit_id
|
||||
LIMIT 1) AS course_title
|
||||
FROM units u
|
||||
WHERE u."deletedAt" IS NULL
|
||||
ORDER BY u.title ASC
|
||||
`, { type: sequelize.QueryTypes.SELECT });
|
||||
return R.success(res, "Units retrieved.", rows);
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][GET FLAT]", err);
|
||||
return R.error(res, "Could not retrieve units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Bundle picker (Tier Plan wizard) — mirrors getCoursesBySubscription in
|
||||
// controllers/admin/courses.controller.js.
|
||||
exports.getUnitsBySubscription = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
if (!slug) return R.error(res, 'slug query param is required.', 400);
|
||||
|
||||
const rows = await Unit.findAll({
|
||||
where: { ...notDeleted, subscription: slug },
|
||||
attributes: ['unit_id', 'title', 'description', 'subscription'],
|
||||
order: [['title', 'ASC']],
|
||||
});
|
||||
|
||||
// A unit may belong to any number of other plans (Tier Plans v2, silent
|
||||
// duplication across bundles is intentional) — no conflict to report here.
|
||||
const data = rows.map((u) => u.toJSON());
|
||||
|
||||
return R.success(res, 'Units retrieved.', data);
|
||||
} catch (err) {
|
||||
console.error('[UNIT LIB][BY SUBSCRIPTION]', err);
|
||||
return R.error(res, 'Could not retrieve units.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnit = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({
|
||||
where: { unit_id: unitId, ...notDeleted },
|
||||
include: [
|
||||
{ model: Lesson, as: "lessons", where: notDeleted, required: false, through: { attributes: ["order_index"] } },
|
||||
{ model: UnitQuiz, as: "quiz", required: false },
|
||||
{ model: Course, as: "courses", where: notDeleted, required: false, attributes: ["course_id", "uuid", "title", "subscription"], through: { attributes: ["order_index"] } },
|
||||
],
|
||||
});
|
||||
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const plain = unit.toJSON();
|
||||
plain.lessons = flattenLessons(plain.lessons);
|
||||
return R.success(res, "Unit retrieved.", { data: plain });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][GET ONE]", err);
|
||||
return R.error(res, "Could not retrieve unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.createUnit = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { title, description, subscription, course_id, order, createdBy } = req.body;
|
||||
if (!title) return R.error(res, "Title is required.", 400);
|
||||
|
||||
const unit = await Unit.create({
|
||||
title,
|
||||
subscription: subscription || null,
|
||||
description: description ?? null,
|
||||
duration_seconds: 0,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
}, { transaction: t });
|
||||
|
||||
// Optional immediate attach — lets the course builder create-and-attach in one call
|
||||
if (course_id) {
|
||||
const course = await Course.findOne({ where: { course_id, ...notDeleted }, transaction: t });
|
||||
if (!course) {
|
||||
await t.rollback();
|
||||
return R.error(res, "Course not found.", 404);
|
||||
}
|
||||
const order_index = order ?? await nextOrderIndex(CourseUnit, { course_id }, t);
|
||||
await CourseUnit.create({
|
||||
course_id,
|
||||
unit_id: unit.unit_id,
|
||||
order_index,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
}, { transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user?.user_id, "create_unit", { entityType: "unit", entityId: unit.unit_id, details: { title: unit.title, attached_course_id: course_id ?? null } });
|
||||
return R.success(res, "Unit created.", { data: unit }, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][CREATE]", err);
|
||||
return R.error(res, "Could not create unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// One consolidated call: creates the unit, its lessons (with objectives + page
|
||||
// blocks), and attaches each lesson to the unit — all in a single transaction.
|
||||
// Body: { title, description, subscription,
|
||||
// lessons: [{ title, description, objectives?: string[], blocks?: [] }],
|
||||
// createdBy }
|
||||
const CREATE_UNIT_FULL_LIMITS = { maxLessons: 50, maxObjectives: 20, maxBlocks: 100 };
|
||||
|
||||
exports.createUnitFull = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { title, description, subscription, lessons = [], createdBy } = req.body;
|
||||
|
||||
if (!title) return R.error(res, "Title is required.", 400);
|
||||
if (!Array.isArray(lessons)) return R.error(res, "Lessons must be a list.", 400);
|
||||
if (lessons.length > CREATE_UNIT_FULL_LIMITS.maxLessons) {
|
||||
return R.error(res, `A unit can have at most ${CREATE_UNIT_FULL_LIMITS.maxLessons} lessons.`, 400);
|
||||
}
|
||||
|
||||
for (const lessonInput of lessons) {
|
||||
if (!lessonInput?.title) return R.error(res, "Each lesson needs a title.", 400);
|
||||
if (lessonInput.objectives !== undefined && !Array.isArray(lessonInput.objectives)) {
|
||||
return R.error(res, "Lesson objectives must be a list.", 400);
|
||||
}
|
||||
if (lessonInput.objectives?.length > CREATE_UNIT_FULL_LIMITS.maxObjectives) {
|
||||
return R.error(res, `A lesson can have at most ${CREATE_UNIT_FULL_LIMITS.maxObjectives} objectives.`, 400);
|
||||
}
|
||||
if (lessonInput.blocks !== undefined && !Array.isArray(lessonInput.blocks)) {
|
||||
return R.error(res, "Lesson page blocks must be a list.", 400);
|
||||
}
|
||||
if (lessonInput.blocks?.length > CREATE_UNIT_FULL_LIMITS.maxBlocks) {
|
||||
return R.error(res, `A lesson page can have at most ${CREATE_UNIT_FULL_LIMITS.maxBlocks} blocks.`, 400);
|
||||
}
|
||||
if (lessonInput.requirements !== undefined) {
|
||||
if (!Array.isArray(lessonInput.requirements)) return R.error(res, "Lesson requirements must be a list.", 400);
|
||||
const seenTypes = new Set();
|
||||
for (const r of lessonInput.requirements) {
|
||||
const allowedEntityTypes = VALID_ENTITY_TYPES[r.type];
|
||||
if (!allowedEntityTypes) return R.error(res, `Unknown requirement type "${r.type}".`, 400);
|
||||
if (!allowedEntityTypes.includes("lesson")) return R.error(res, `"${r.type}" cannot be configured on a lesson.`, 400);
|
||||
if (seenTypes.has(r.type)) return R.error(res, `Duplicate "${r.type}" requirement — only one per lesson is allowed.`, 400);
|
||||
seenTypes.add(r.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const by = createdBy ?? req.user?.user_id ?? null;
|
||||
|
||||
const unit = await Unit.create({
|
||||
title,
|
||||
subscription: subscription || null,
|
||||
description: description ?? null,
|
||||
duration_seconds: 0,
|
||||
createdBy: by,
|
||||
}, { transaction: t });
|
||||
|
||||
for (let i = 0; i < lessons.length; i++) {
|
||||
const lessonInput = lessons[i];
|
||||
|
||||
const lesson = await Lesson.create({
|
||||
title: lessonInput.title,
|
||||
description: lessonInput.description ?? null,
|
||||
duration_seconds: 0,
|
||||
createdBy: by,
|
||||
}, { transaction: t });
|
||||
|
||||
await LessonPage.create({
|
||||
lesson_id: lesson.lesson_id,
|
||||
blocks: lessonInput.blocks ?? [],
|
||||
createdBy: by,
|
||||
}, { transaction: t });
|
||||
|
||||
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, lessonInput.objectives ?? [], t);
|
||||
|
||||
if (lessonInput.requirements?.length) {
|
||||
const rows = lessonInput.requirements.map((r, ri) => ({
|
||||
entity_type: "lesson",
|
||||
entity_id: lesson.lesson_id,
|
||||
type: r.type,
|
||||
order: r.order ?? ri,
|
||||
min_percent: r.type === "watch_percent" ? (r.min_percent ?? 100) : null,
|
||||
button_label: r.type === "manual_complete" ? (r.button_label || null) : null,
|
||||
is_required: r.is_required ?? true,
|
||||
createdBy: by,
|
||||
updatedBy: by,
|
||||
}));
|
||||
await CompletionRequirement.bulkCreate(rows, { transaction: t });
|
||||
}
|
||||
|
||||
await UnitLesson.create({
|
||||
unit_id: unit.unit_id,
|
||||
lesson_id: lesson.lesson_id,
|
||||
order_index: i,
|
||||
createdBy: by,
|
||||
}, { transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
if (lessons.length) {
|
||||
try { await recomputeUnitDuration(unit.unit_id); }
|
||||
catch (durErr) { console.error("[UNIT LIB][CREATE FULL][DURATION]", durErr); }
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, "create_unit", { entityType: "unit", entityId: unit.unit_id, details: { title: unit.title, lessons: lessons.length } });
|
||||
return R.success(res, "Unit created.", { data: unit }, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][CREATE FULL]", err);
|
||||
return R.error(res, "Could not create unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateUnit = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const { title, description, subscription, updatedBy } = req.body;
|
||||
|
||||
if (title !== undefined) unit.title = title;
|
||||
if (description !== undefined) unit.description = description;
|
||||
if (subscription !== undefined) unit.subscription = subscription || null;
|
||||
unit.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
||||
|
||||
await unit.save();
|
||||
logActivity(req.user?.user_id, "update_unit", { entityType: "unit", entityId: Number(unitId) });
|
||||
return R.success(res, "Unit updated.", { data: unit });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][UPDATE]", err);
|
||||
return R.error(res, "Could not update unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitArchiveImpact = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const [completionCount, progressCount, courseCount] = await Promise.all([
|
||||
UnitReadingProgress.count({ where: { unit_id: unitId, status: "completed" } }),
|
||||
LessonReadingProgress.count({ where: { unit_id: unitId }, distinct: true, col: "user_id" }),
|
||||
CourseUnit.count({ where: { unit_id: unitId } }),
|
||||
]);
|
||||
|
||||
return R.success(res, "Impact retrieved.", { completionCount, progressCount, courseCount });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][ARCHIVE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.archiveUnit = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const record = await archiveOne(Unit, { unit_id: unitId, ...notDeleted }, req.user.user_id, t);
|
||||
if (!record) return R.error(res, "Unit not found.", 404);
|
||||
await t.commit();
|
||||
try { await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][ARCHIVE][DURATION]", durErr); }
|
||||
logActivity(req.user.user_id, "archive_unit", { entityType: "unit", entityId: Number(unitId) });
|
||||
return R.success(res, "Unit archived.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][ARCHIVE]", err);
|
||||
return R.error(res, "Could not archive unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkArchiveUnits = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const units = await Unit.findAll({ where: { unit_id: ids, ...notDeleted } });
|
||||
const validIds = units.map((u) => u.unit_id);
|
||||
|
||||
const count = await archiveMany(Unit, "unit_id", validIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
for (const id of validIds) {
|
||||
try { await recomputeParentCourseDurations(id); } catch (durErr) { console.error("[UNIT LIB][BULK ARCHIVE][DURATION]", durErr); }
|
||||
}
|
||||
logActivity(req.user.user_id, "bulk_archive_units", { entityType: "unit", details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} archived.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][BULK ARCHIVE]", err);
|
||||
return R.error(res, "Could not archive units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getArchivedUnits = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Unit, req, {
|
||||
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
||||
context: "archived",
|
||||
findOptions: {
|
||||
where: { ...onlyDeleted },
|
||||
paranoid: false,
|
||||
order: [["deletedAt", "DESC"]],
|
||||
},
|
||||
});
|
||||
return R.success(res, "Archived units retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][GET ARCHIVES]", err);
|
||||
return R.error(res, "Could not retrieve archived units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getArchivedUnit = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const unit = await Unit.findOne({
|
||||
where: { unit_id: unitId, ...onlyDeleted },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!unit) return R.error(res, "Archived unit not found.", 404);
|
||||
return R.success(res, "Archived unit retrieved.", { data: unit.toJSON() });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][GET ARCHIVE ONE]", err);
|
||||
return R.error(res, "Could not retrieve archived unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.restoreUnit = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const record = await restoreOne(Unit, { unit_id: unitId, ...onlyDeleted }, req.user.user_id, t);
|
||||
if (!record) return R.error(res, "Archived unit not found.", 404);
|
||||
await t.commit();
|
||||
try { await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][RESTORE][DURATION]", durErr); }
|
||||
logActivity(req.user.user_id, "restore_unit", { entityType: "unit", entityId: Number(unitId) });
|
||||
return R.success(res, "Unit restored.", { data: record });
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][RESTORE]", err);
|
||||
return R.error(res, "Could not restore unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkRestoreUnits = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const units = await Unit.findAll({ where: { unit_id: ids, ...onlyDeleted }, paranoid: false });
|
||||
const validIds = units.map((u) => u.unit_id);
|
||||
|
||||
const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
for (const id of validIds) {
|
||||
try { await recomputeParentCourseDurations(id); } catch (durErr) { console.error("[UNIT LIB][BULK RESTORE][DURATION]", durErr); }
|
||||
}
|
||||
logActivity(req.user.user_id, "bulk_restore_units", { entityType: "unit", details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} restored.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][BULK RESTORE]", err);
|
||||
return R.error(res, "Could not restore units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const [lessonCount, courseCount] = await Promise.all([
|
||||
UnitLesson.count({ where: { unit_id: unitId } }),
|
||||
CourseUnit.count({ where: { unit_id: unitId } }),
|
||||
]);
|
||||
|
||||
return R.success(res, "Impact retrieved.", { lessonCount, courseCount });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][PERMANENT DELETE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteUnit = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const record = await permanentDeleteOne(Unit, { unit_id: unitId }, t);
|
||||
if (record === null) return R.error(res, "Unit not found.", 404);
|
||||
if (record === false) return R.error(res, "Unit must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
// Junction rows don't cascade from a paranoid destroy — clean them explicitly
|
||||
await CourseUnit.destroy({ where: { unit_id: unitId }, transaction: t });
|
||||
await UnitLesson.destroy({ where: { unit_id: unitId }, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, "permanently_delete_unit", { entityType: "unit", entityId: Number(unitId) });
|
||||
return R.success(res, "Unit permanently deleted.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteUnits = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const units = await Unit.findAll({ where: { unit_id: ids }, paranoid: false });
|
||||
const validIds = units.map((u) => u.unit_id);
|
||||
|
||||
const count = await permanentDeleteMany(Unit, "unit_id", validIds, t);
|
||||
await CourseUnit.destroy({ where: { unit_id: validIds }, transaction: t });
|
||||
await UnitLesson.destroy({ where: { unit_id: validIds }, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, "bulk_permanently_delete_units", { entityType: "unit", details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} permanently deleted.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitFieldValues = getFieldValues(Unit, "UNIT");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// UNIT ⇄ LESSON MEMBERSHIP (attach / detach / reorder)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// POST /admin/units/:unitId/lessons { lesson_ids: [..] } — append existing lessons
|
||||
exports.attachLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const { lesson_ids = [] } = req.body;
|
||||
if (!lesson_ids.length) return R.error(res, "lesson_ids is required.", 400);
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, transaction: t });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const lessons = await Lesson.findAll({ where: { lesson_id: lesson_ids, ...notDeleted }, transaction: t });
|
||||
if (lessons.length !== lesson_ids.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, "One or more lessons were not found.", 404);
|
||||
}
|
||||
|
||||
const existing = await UnitLesson.findAll({ where: { unit_id: unitId, lesson_id: lesson_ids }, transaction: t });
|
||||
const existingSet = new Set(existing.map((r) => String(r.lesson_id)));
|
||||
const toAttach = lesson_ids.filter((id) => !existingSet.has(String(id)));
|
||||
|
||||
let order = await nextOrderIndex(UnitLesson, { unit_id: unitId }, t);
|
||||
await UnitLesson.bulkCreate(
|
||||
toAttach.map((lesson_id) => ({
|
||||
unit_id: unitId,
|
||||
lesson_id,
|
||||
order_index: order++,
|
||||
createdBy: req.user?.user_id ?? null,
|
||||
})),
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
await t.commit();
|
||||
try { await recomputeUnitDuration(unitId); await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][ATTACH LESSONS][DURATION]", durErr); }
|
||||
logActivity(req.user?.user_id, "attach_lessons", { entityType: "unit", entityId: Number(unitId), details: { lesson_ids: toAttach } });
|
||||
return R.success(res, `${toAttach.length} lesson${toAttach.length !== 1 ? "s" : ""} attached.`, { attached: toAttach, skipped: lesson_ids.filter((id) => existingSet.has(String(id))) });
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][ATTACH LESSONS]", err);
|
||||
return R.error(res, "Could not attach lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /admin/units/:unitId/lessons/:lessonId — detach (lesson survives in the library)
|
||||
exports.detachLesson = async (req, res) => {
|
||||
try {
|
||||
const { unitId, lessonId } = req.params;
|
||||
|
||||
const removed = await UnitLesson.destroy({ where: { unit_id: unitId, lesson_id: lessonId } });
|
||||
if (!removed) return R.error(res, "Lesson is not attached to this unit.", 404);
|
||||
|
||||
try { await recomputeUnitDuration(unitId); await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][DETACH LESSON][DURATION]", durErr); }
|
||||
logActivity(req.user?.user_id, "detach_lesson", { entityType: "unit", entityId: Number(unitId), details: { lesson_id: Number(lessonId) } });
|
||||
return R.success(res, "Lesson detached.");
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][DETACH LESSON]", err);
|
||||
return R.error(res, "Could not detach lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /admin/units/:unitId/lessons/order { lesson_ids: [orderedIds] }
|
||||
exports.reorderLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const { lesson_ids = [] } = req.body;
|
||||
if (!lesson_ids.length) return R.error(res, "lesson_ids is required.", 400);
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, transaction: t });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
await reorderJunction(UnitLesson, "unit_id", unitId, "lesson_id", lesson_ids, t);
|
||||
await t.commit();
|
||||
logActivity(req.user?.user_id, "reorder_lessons", { entityType: "unit", entityId: Number(unitId), details: { lesson_ids } });
|
||||
return R.success(res, "Lesson order updated.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][REORDER LESSONS]", err);
|
||||
return R.error(res, "Could not reorder lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// UNIT QUIZ (1:1 with the Unit — travels with it into every attached course)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
exports.getQuiz = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const quiz = await UnitQuiz.findOne({
|
||||
where: { unit_id: unitId, ...notDeleted },
|
||||
include: [{
|
||||
model: QuizQuestion, as: "questions",
|
||||
where: notDeleted, required: false,
|
||||
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
|
||||
}],
|
||||
});
|
||||
|
||||
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
||||
return R.success(res, "Quiz retrieved.", { data: quiz });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][QUIZ][GET]", err);
|
||||
return R.error(res, "Could not retrieve quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.createQuiz = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const { title, is_required, passing_score, max_questions, shuffle_questions, createdBy } = req.body;
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const existing = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
||||
if (existing) return R.error(res, "Quiz already exists for this unit.", 409);
|
||||
|
||||
const quiz = await UnitQuiz.create({
|
||||
unit_id: unitId,
|
||||
title: title ?? null,
|
||||
is_required: is_required ?? false,
|
||||
passing_score: passing_score ?? 70,
|
||||
max_questions: max_questions ?? null,
|
||||
shuffle_questions: shuffle_questions ?? false,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, "create_quiz", { entityType: "quiz", entityId: quiz.quiz_id });
|
||||
return R.success(res, "Quiz created.", { data: quiz }, 201);
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][QUIZ][CREATE]", err);
|
||||
return R.error(res, "Could not create quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateQuiz = async (req, res) => {
|
||||
try {
|
||||
const { unitId, quizId } = req.params;
|
||||
const { title, is_required, passing_score, max_questions, shuffle_questions, updatedBy } = req.body;
|
||||
|
||||
const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted } });
|
||||
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
||||
|
||||
if (title !== undefined) quiz.title = title;
|
||||
if (is_required !== undefined) quiz.is_required = is_required;
|
||||
if (passing_score !== undefined) quiz.passing_score = passing_score;
|
||||
if (max_questions !== undefined) quiz.max_questions = max_questions;
|
||||
if (shuffle_questions !== undefined) quiz.shuffle_questions = shuffle_questions;
|
||||
quiz.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
||||
|
||||
await quiz.save();
|
||||
logActivity(req.user?.user_id, "update_quiz", { entityType: "quiz", entityId: Number(quizId) });
|
||||
return R.success(res, "Quiz updated.", { data: quiz });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][QUIZ][UPDATE]", err);
|
||||
return R.error(res, "Could not update quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteQuiz = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId, quizId } = req.params;
|
||||
const record = await archiveOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...notDeleted }, req.user?.user_id, t);
|
||||
if (!record) return R.error(res, "Quiz not found.", 404);
|
||||
await t.commit();
|
||||
logActivity(req.user?.user_id, "archive_quiz", { entityType: "quiz", entityId: Number(quizId) });
|
||||
return R.success(res, "Quiz archived.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][QUIZ][ARCHIVE]", err);
|
||||
return R.error(res, "Could not archive quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getArchivedQuiz = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId }, paranoid: false });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const quiz = await UnitQuiz.findOne({
|
||||
where: { unit_id: unitId, ...onlyDeleted },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!quiz) return R.error(res, "Archived quiz not found.", 404);
|
||||
return R.success(res, "Archived quiz retrieved.", { data: quiz.toJSON() });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][QUIZ][GET ARCHIVE]", err);
|
||||
return R.error(res, "Could not retrieve archived quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.restoreQuiz = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId, quizId } = req.params;
|
||||
const record = await restoreOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...onlyDeleted }, req.user?.user_id, t);
|
||||
if (!record) return R.error(res, "Archived quiz not found.", 404);
|
||||
await t.commit();
|
||||
logActivity(req.user?.user_id, "restore_quiz", { entityType: "quiz", entityId: Number(quizId) });
|
||||
return R.success(res, "Quiz restored.", { data: record });
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][QUIZ][RESTORE]", err);
|
||||
return R.error(res, "Could not restore quiz.", 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: user_activity.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin-only view of the user_activity log.
|
||||
*
|
||||
* GET /api/admin/activity → global paginated activity feed (all users)
|
||||
* GET /api/admin/users/:id/activity → paginated activity for a single user
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 21, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_UserActivity = require('../../models/users/user_activity.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
const USER_ATTRS = [
|
||||
'user_id', 'email', 'acc_type',
|
||||
// full_name from JSONB — resolved in the association below
|
||||
];
|
||||
|
||||
// ─── Shared query builder ─────────────────────────────────────────────────────
|
||||
|
||||
function buildWhere(query, extraWhere = {}) {
|
||||
const where = { ...extraWhere };
|
||||
|
||||
if (query.action)
|
||||
where.action = query.action;
|
||||
|
||||
if (query.from || query.to) {
|
||||
where.created_at = {};
|
||||
if (query.from) where.created_at[Op.gte] = new Date(query.from);
|
||||
if (query.to) {
|
||||
// `to` arrives as a date-only string (e.g. "2026-06-04"), which parses
|
||||
// to that day's UTC midnight — an Op.lte against midnight excludes
|
||||
// every event that happened later the same day. Push it to the last
|
||||
// instant of that calendar day instead.
|
||||
const to = new Date(query.to);
|
||||
to.setUTCHours(23, 59, 59, 999);
|
||||
where.created_at[Op.lte] = to;
|
||||
}
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
// ─── GET GLOBAL ACTIVITY FEED ─────────────────────────────────────────────────
|
||||
|
||||
exports.getActivity = async (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(100, parseInt(req.query.limit) || 20);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where = buildWhere(req.query);
|
||||
|
||||
const { count, rows } = await mdl_UserActivity.findAndCountAll({
|
||||
where,
|
||||
include: [{
|
||||
model: mdl_Users,
|
||||
as: 'user',
|
||||
attributes: ['user_id', 'email', 'acc_type', 'personal_info'],
|
||||
}],
|
||||
order: [['created_at', 'DESC']],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
const data = await Promise.all(rows.map(formatRow));
|
||||
|
||||
return R.success(res, 'Activity feed retrieved.', {
|
||||
total: count,
|
||||
page,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
activities: data,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ACTIVITY FEED]', err);
|
||||
return R.error(res, 'Could not retrieve activity feed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET PER-USER ACTIVITY ────────────────────────────────────────────────────
|
||||
|
||||
exports.getUserActivity = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id || id === 'undefined') return R.error(res, 'Invalid User ID.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(id, { attributes: ['user_id'] });
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(100, parseInt(req.query.limit) || 20);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where = buildWhere(req.query, { user_id: id });
|
||||
|
||||
const { count, rows } = await mdl_UserActivity.findAndCountAll({
|
||||
where,
|
||||
order: [['created_at', 'DESC']],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return R.success(res, 'User activity retrieved.', {
|
||||
total: count,
|
||||
page,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
activities: rows,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USER ACTIVITY]', err);
|
||||
return R.error(res, 'Could not retrieve user activity.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function formatRow(row) {
|
||||
const r = row.toJSON();
|
||||
const info = r.user?.personal_info;
|
||||
const avatar = await resolveAvatarUrl(info?.avatar);
|
||||
return {
|
||||
activity_id: r.activity_id,
|
||||
user_id: r.user_id,
|
||||
email: r.user?.email ?? null,
|
||||
full_name: info?.name?.full_name ?? null,
|
||||
avatar_stream_token: avatar?.stream_token ?? null,
|
||||
acc_type: r.user?.acc_type ?? null,
|
||||
action: r.action,
|
||||
entity_type: r.entity_type,
|
||||
entity_id: r.entity_id,
|
||||
details: r.details,
|
||||
created_at: r.created_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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
|
||||
***********************************************************************************************************************************************************************
|
||||
* Change History:
|
||||
* DATE AUTHOR LOG DESCRIPTION
|
||||
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
|
||||
* May 23, 2026 rgrgogu 002 Added group_code to createGroup / updateGroup; added generateGroupCode helper
|
||||
***********************************************************************************************************************************************************************/
|
||||
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 { getFieldValues } = require('../../utils/fieldValues.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 logActivity = require('../../utils/logActivity.util');
|
||||
const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../utils/defaultGroup.util');
|
||||
|
||||
// ─── Helper — generate a unique group code ────────────────────────────────────
|
||||
/**
|
||||
* Generates a unique group_code in the format: <SLUG>-<4-char hex>
|
||||
* e.g. "SALES-A3F1", "ONBOARD-Q1-9C2D"
|
||||
* Retries up to 5 times in the unlikely event of a collision.
|
||||
*/
|
||||
const generateGroupCode = async (name) => {
|
||||
const slug = name.toUpperCase().trim().replace(/\s+/g, '-').replace(/[^A-Z0-9\-]/g, '').slice(0, 20);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const suffix = Math.random().toString(16).slice(2, 6).toUpperCase();
|
||||
const code = `${slug}-${suffix}`;
|
||||
const exists = await mdl_UserGroups.findOne({ where: { group_code: code }, paranoid: false });
|
||||
if (!exists) return code;
|
||||
}
|
||||
throw new Error('Could not generate a unique group code after 5 attempts.');
|
||||
};
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
exports.getGroups = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(mdl_UserGroups, req, {
|
||||
excludeAttributes: groupExclude,
|
||||
jsonbSchemas: groupSchemas,
|
||||
computedAttributes: groupComputed,
|
||||
context: 'list',
|
||||
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' },
|
||||
context: 'list',
|
||||
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, group_code } = req.body;
|
||||
if (!name) return R.error(res, 'Group name is required.', 400);
|
||||
|
||||
// Use provided group_code (uppercased via model hook), otherwise auto-generate
|
||||
const code = group_code
|
||||
? group_code.toUpperCase().trim()
|
||||
: await generateGroupCode(name);
|
||||
|
||||
// Check uniqueness explicitly so we return a clear error message
|
||||
const duplicate = await mdl_UserGroups.findOne({ where: { group_code: code }, paranoid: false });
|
||||
if (duplicate) return R.error(res, `Group code "${code}" is already in use.`, 409);
|
||||
|
||||
const group = await mdl_UserGroups.create({
|
||||
name,
|
||||
description,
|
||||
group_code: code,
|
||||
createdBy: req.user.user_id,
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'create_group', { entityType: 'group', entityId: group.group_id, details: { name: group.name, group_code: group.group_code } });
|
||||
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, group_code } = req.body;
|
||||
|
||||
if (name !== undefined) group.name = name;
|
||||
if (description !== undefined) group.description = description;
|
||||
|
||||
if (group_code !== undefined) {
|
||||
const code = group_code.toUpperCase().trim();
|
||||
const duplicate = await mdl_UserGroups.findOne({
|
||||
where: { group_code: code, group_id: { [Op.ne]: group.group_id } },
|
||||
paranoid: false,
|
||||
});
|
||||
if (duplicate) return R.error(res, `Group code "${code}" is already in use.`, 409);
|
||||
group.group_code = code;
|
||||
}
|
||||
|
||||
group.updatedBy = req.user.user_id;
|
||||
await group.save();
|
||||
|
||||
logActivity(req.user.user_id, 'update_group', { entityType: 'group', entityId: group.group_id });
|
||||
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();
|
||||
|
||||
logActivity(req.user.user_id, 'deactivate_group', { entityType: 'group', entityId: group.group_id });
|
||||
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 });
|
||||
|
||||
logActivity(req.user.user_id, 'restore_group', { entityType: 'group', entityId: group.group_id });
|
||||
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 } });
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_deactivate_groups', { entityType: 'group', details: { ids: activeIds, count: activeIds.length } });
|
||||
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 }
|
||||
);
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_restore_groups', { entityType: 'group', details: { ids: deletedIds, count: deletedIds.length } });
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
exports.permanentlyDeleteGroup = 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.deletedAt) return R.error(res, 'Group must be deactivated before it can be permanently deleted.', 400);
|
||||
|
||||
await group.destroy({ force: true });
|
||||
|
||||
logActivity(req.user.user_id, 'permanently_delete_group', { entityType: 'group', entityId: group.group_id });
|
||||
return R.success(res, 'Group permanently deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PERMANENT DELETE GROUP]', err);
|
||||
return R.error(res, 'Could not permanently delete group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
exports.bulkPermanentlyDeleteGroups = 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 must be deactivated before they can be permanently deleted.', 400);
|
||||
|
||||
const deletedIds = deletedGroups.map((g) => g.group_id);
|
||||
|
||||
await mdl_UserGroups.destroy({ where: { group_id: deletedIds }, force: true });
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_groups', { entityType: 'group', details: { ids: deletedIds, count: deletedIds.length } });
|
||||
return R.success(res, `${deletedIds.length} group(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE GROUPS]', err);
|
||||
return R.error(res, 'Could not permanently delete groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED ─────────────────────────────────────────────────────────────────
|
||||
exports.getArchivedGroups = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(mdl_UserGroups, req, {
|
||||
excludeAttributes: groupExclude,
|
||||
jsonbSchemas: groupSchemas,
|
||||
computedAttributes: groupComputed,
|
||||
context: 'archived',
|
||||
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 = getFieldValues(mdl_UserGroups, 'GROUP', {
|
||||
blockedFields: ['deletedAt'],
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
// ─── MEMBERSHIP ───────────────────────────────────────────────────────────────
|
||||
exports.getUsersNotInGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
|
||||
// Exclude users already in THIS group
|
||||
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] },
|
||||
acc_type: 'user', // exclude staff/admin — only regular users can be added to a group
|
||||
},
|
||||
attributes: [
|
||||
'user_id',
|
||||
[Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||
// All active groups this user belongs to (excluding NOGRP), comma-separated
|
||||
[
|
||||
Sequelize.literal(`(
|
||||
SELECT STRING_AGG(ug.name || ' (' || ug.group_code || ')', ', ' ORDER BY ug.name)
|
||||
FROM user_group_members ugm
|
||||
JOIN user_groups ug ON ug.group_id = ugm.group_id
|
||||
WHERE ugm.user_id = "User".user_id
|
||||
AND ugm."deletedAt" IS NULL
|
||||
AND ug."deletedAt" IS NULL
|
||||
AND ug.group_code != 'NOGRP'
|
||||
)`),
|
||||
'current_group',
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
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 }
|
||||
);
|
||||
|
||||
await dropDefaultGroupMembership(user_ids, group_id, { updatedBy: req.user.user_id });
|
||||
|
||||
logActivity(req.user.user_id, 'add_user_to_group', { entityType: 'group', entityId: Number(group_id), details: { user_ids } });
|
||||
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 } });
|
||||
|
||||
await reconcileDefaultGroup(user_ids, { createdBy: req.user.user_id });
|
||||
|
||||
logActivity(req.user.user_id, 'remove_user_from_group', { entityType: 'group', entityId: Number(group_id), details: { user_ids } });
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,939 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: users.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin-level user management — full CRUD on any user.
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const sequelize = require('../../config/db.config');
|
||||
const { Op, Sequelize } = require('sequelize');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||
const mdl_Achievements = require('../../models/users/achievements.mdl');
|
||||
const mdl_UserBans = require('../../models/users/user_bans.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||
const mdl_QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
|
||||
|
||||
const { sendEmail } = require('../../services/email.service');
|
||||
const trustedDevice = require('../../services/trustedDevice.service');
|
||||
const { fmtDate } = require('../../utils/datetime.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: userComputed } = require('../../models/users/users.attributes');
|
||||
|
||||
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at'];
|
||||
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
|
||||
|
||||
// ─── GROUPS FILTER ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// "groups" is a M2M association, not a real column on `users` — buildQuery's
|
||||
// generic Sequelize.col()-based filtering can't touch it. Pull any `groups`
|
||||
// filter out of the request's filter list (so paginate() never sees a field
|
||||
// it can't resolve) and turn it into an EXISTS subquery instead. EXISTS keeps
|
||||
// the LEFT JOIN group data on each row intact (all of a user's groups still
|
||||
// show up), while only matching users who belong to at least one of the
|
||||
// selected groups.
|
||||
function extractGroupsFilter(req) {
|
||||
let filters = [];
|
||||
try { filters = JSON.parse(req.query.filters || '[]'); } catch { filters = []; }
|
||||
|
||||
const groupsFilter = filters.find((f) => f.id === 'groups');
|
||||
const remaining = filters.filter((f) => f.id !== 'groups');
|
||||
req.query.filters = JSON.stringify(remaining);
|
||||
|
||||
if (!groupsFilter?.value) return null;
|
||||
|
||||
const groupIds = (Array.isArray(groupsFilter.value) ? groupsFilter.value : [groupsFilter.value])
|
||||
.map((v) => parseInt(v, 10))
|
||||
.filter((v) => !Number.isNaN(v));
|
||||
|
||||
return groupIds.length ? groupIds : null;
|
||||
}
|
||||
|
||||
function groupsExistsWhere(groupIds) {
|
||||
if (!groupIds) return undefined;
|
||||
return Sequelize.literal(`EXISTS (
|
||||
SELECT 1 FROM "user_group_members" ugm
|
||||
WHERE ugm.user_id = "User"."user_id"
|
||||
AND ugm."deletedAt" IS NULL
|
||||
AND ugm.group_id IN (${groupIds.join(',')})
|
||||
)`);
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getUsers = async (req, res) => {
|
||||
try {
|
||||
const groupIds = extractGroupsFilter(req);
|
||||
|
||||
const result = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
computedAttributes: userComputed,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
findOptions: {
|
||||
where: groupsExistsWhere(groupIds),
|
||||
include: [{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
through: { attributes: [] }, // hide junction columns
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
required: false, // LEFT JOIN — users with no group still appear
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Users retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ALL USERS]', err);
|
||||
return R.error(res, 'Could not retrieve users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getUser = async (req, res) => {
|
||||
try {
|
||||
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',
|
||||
through: { attributes: [] },
|
||||
}],
|
||||
});
|
||||
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
return R.success(res, 'User retrieved.', await resolveUserAvatar(user));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USER]', err);
|
||||
return R.error(res, 'Could not retrieve user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ADD STAFF ────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.addStaffUser = async (req, res) => {
|
||||
try {
|
||||
const { email, personal_info = {} } = req.body;
|
||||
|
||||
if (!email)
|
||||
return R.error(res, 'Email is required.', 400);
|
||||
|
||||
if (!personal_info?.name?.given_name || !personal_info?.name?.last_name)
|
||||
return R.error(res, 'First name and last name are required.', 400);
|
||||
|
||||
const existing = await mdl_Users.findOne({ where: { email } });
|
||||
if (existing) return R.error(res, 'Email is already in use.', 409);
|
||||
|
||||
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 user = await mdl_Users.create({
|
||||
email,
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'create_staff', { entityType: 'user', entityId: user.user_id, details: { email } });
|
||||
|
||||
return R.success(res, 'Staff user created successfully.', {
|
||||
user_id: user.user_id,
|
||||
email: user.email,
|
||||
acc_type: user.acc_type,
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][ADD STAFF USER]', err);
|
||||
return R.error(res, 'Could not create staff user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.params.id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const allowed = ['acc_type', 'is_active', 'personal_info'];
|
||||
const updates = {};
|
||||
allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; });
|
||||
|
||||
if (Number(req.params.id) === req.user.user_id && updates.acc_type !== undefined)
|
||||
return R.error(res, 'Admins cannot change their own role.', 400);
|
||||
|
||||
if (updates.personal_info)
|
||||
updates.personal_info = enrichPersonalInfo(updates.personal_info);
|
||||
|
||||
updates.updatedBy = req.user.user_id;
|
||||
|
||||
await user.update(updates);
|
||||
|
||||
logActivity(req.user.user_id, 'update_user', { entityType: 'user', entityId: Number(req.params.id) });
|
||||
|
||||
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);
|
||||
return R.error(res, 'Could not update user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DEACTIVATE (soft delete) ─────────────────────────────────────────────────
|
||||
|
||||
exports.deactivateUser = async (req, res) => {
|
||||
try {
|
||||
if (Number(req.params.id) === req.user.user_id)
|
||||
return R.error(res, 'You cannot deactivate your own account.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(req.params.id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
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 });
|
||||
await user.destroy();
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: req.user.user_id },
|
||||
{ where: { user_id: req.params.id } }
|
||||
);
|
||||
await mdl_UserGroupMembers.destroy({ where: { user_id: req.params.id } });
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: req.params.id } }
|
||||
);
|
||||
await trustedDevice.revokeAllForUser(req.params.id);
|
||||
|
||||
logActivity(req.user.user_id, 'deactivate_user', { entityType: 'user', entityId: Number(req.params.id) });
|
||||
|
||||
return R.success(res, 'User deactivated successfully.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DEACTIVATE USER]', err);
|
||||
return R.error(res, 'Could not deactivate user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK DEACTIVATE ──────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkDeactivateUsers = async (req, res) => {
|
||||
try {
|
||||
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 } });
|
||||
if (!users.length) return R.error(res, 'No users found.', 404);
|
||||
|
||||
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);
|
||||
|
||||
await mdl_Users.update(
|
||||
{ is_active: false, deletedBy: req.user.user_id },
|
||||
{ where: { user_id: activeIds } }
|
||||
);
|
||||
await mdl_Users.destroy({ where: { user_id: activeIds } });
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: req.user.user_id },
|
||||
{ where: { user_id: activeIds } }
|
||||
);
|
||||
await mdl_UserGroupMembers.destroy({ where: { user_id: activeIds } });
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: activeIds } }
|
||||
);
|
||||
await trustedDevice.revokeAllForUser(activeIds);
|
||||
|
||||
return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, {
|
||||
deactivated_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK DEACTIVATE USERS]', err);
|
||||
return R.error(res, 'Could not deactivate users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id }, paranoid: false,
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400);
|
||||
|
||||
await user.restore();
|
||||
await user.update({ is_active: true, updatedBy: req.user.user_id, deletedBy: null });
|
||||
|
||||
logActivity(req.user.user_id, 'restore_user', { entityType: 'user', entityId: Number(req.params.id) });
|
||||
|
||||
return R.success(res, 'User restored successfully.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][RESTORE USER]', err);
|
||||
return R.error(res, 'Could not restore user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkRestoreUsers = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
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 });
|
||||
if (!users.length) return R.error(res, 'No users found.', 404);
|
||||
|
||||
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 }
|
||||
);
|
||||
|
||||
return R.success(res, `${deletedIds.length} user(s) restored successfully.`, {
|
||||
restored_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK RESTORE USERS]', err);
|
||||
return R.error(res, 'Could not restore users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The live DB has several FKs on users.user_id that are NO ACTION rather than
|
||||
// the CASCADE the migration source claims (schema drift) — achievements,
|
||||
// quiz_attempts, user_tiers, and payments all block a hard delete unless their
|
||||
// rows for this user are removed first. Also null out the "who did this"
|
||||
// actor columns (granted_by/revoked_by/lifted_by) on OTHER users' records,
|
||||
// since those aren't this user's own data and shouldn't be deleted.
|
||||
//
|
||||
// UserBan.banned_by is NOT NULL (a ban record must always keep its banner),
|
||||
// so a user who has ever banned someone can't be nulled out or hard-deleted —
|
||||
// getBannerBlockedIds() below identifies those and callers must exclude them.
|
||||
async function purgeUserDependents(userIds, t) {
|
||||
await mdl_UserGroupMembers.destroy({ where: { user_id: userIds }, force: true, transaction: t });
|
||||
await mdl_Achievements.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_QuizAttempt.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_UserTiers.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_Payments.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
|
||||
await mdl_Achievements.update({ granted_by: null }, { where: { granted_by: userIds }, transaction: t });
|
||||
await mdl_UserTiers.update({ granted_by: null }, { where: { granted_by: userIds }, transaction: t });
|
||||
await mdl_UserTiers.update({ revoked_by: null }, { where: { revoked_by: userIds }, transaction: t });
|
||||
await mdl_UserBans.update({ lifted_by: null }, { where: { lifted_by: userIds }, transaction: t });
|
||||
}
|
||||
|
||||
async function getBannerBlockedIds(userIds, t) {
|
||||
const bans = await mdl_UserBans.findAll({
|
||||
where: { banned_by: userIds }, attributes: ['banned_by'], group: ['banned_by'], transaction: t,
|
||||
});
|
||||
return bans.map((b) => b.banned_by);
|
||||
}
|
||||
|
||||
exports.permanentlyDeleteUser = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
if (Number(req.params.id) === req.user.user_id) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'You cannot permanently delete your own account.', 400);
|
||||
}
|
||||
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id }, paranoid: false, transaction: t,
|
||||
});
|
||||
if (!user) { await t.rollback(); return R.error(res, 'User not found.', 404); }
|
||||
if (!user.deletedAt) { await t.rollback(); return R.error(res, 'User must be deactivated before it can be permanently deleted.', 400); }
|
||||
|
||||
const bannerBlockedIds = await getBannerBlockedIds([user.user_id], t);
|
||||
if (bannerBlockedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Cannot delete: this user has banned other users, and ban records must keep their banner on file.', 400);
|
||||
}
|
||||
|
||||
await purgeUserDependents([user.user_id], t);
|
||||
await user.destroy({ force: true, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user.user_id, 'permanently_delete_user', { entityType: 'user', entityId: Number(req.params.id) });
|
||||
|
||||
return R.success(res, 'User permanently deleted.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][PERMANENT DELETE USER]', err);
|
||||
return R.error(res, 'Could not permanently delete user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkPermanentlyDeleteUsers = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No user IDs provided.', 400);
|
||||
}
|
||||
|
||||
if (ids.includes(req.user.user_id)) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'You cannot permanently delete your own account.', 400);
|
||||
}
|
||||
|
||||
const users = await mdl_Users.findAll({ where: { user_id: ids }, paranoid: false, transaction: t });
|
||||
if (!users.length) { await t.rollback(); return R.error(res, 'No users found.', 404); }
|
||||
|
||||
const deletedUsers = users.filter((u) => u.deletedAt);
|
||||
if (!deletedUsers.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected users must be deactivated before they can be permanently deleted.', 400);
|
||||
}
|
||||
|
||||
const archivedIds = deletedUsers.map((u) => u.user_id);
|
||||
const bannerBlockedIds = await getBannerBlockedIds(archivedIds, t);
|
||||
const deletedIds = archivedIds.filter((id) => !bannerBlockedIds.includes(id));
|
||||
|
||||
if (!deletedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Cannot delete: all selected users have banned other users, and ban records must keep their banner on file.', 400);
|
||||
}
|
||||
|
||||
await purgeUserDependents(deletedIds, t);
|
||||
await mdl_Users.destroy({ where: { user_id: deletedIds }, force: true, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_users', {
|
||||
entityType: 'user',
|
||||
details: { ids: deletedIds, count: deletedIds.length },
|
||||
});
|
||||
|
||||
return R.success(res, `${deletedIds.length} user(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE USERS]', err);
|
||||
return R.error(res, 'Could not permanently delete users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SESSIONS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getUserSessions = async (req, res) => {
|
||||
try {
|
||||
const sessions = await mdl_UserSessions.findAll({
|
||||
where: { user_id: req.params.id },
|
||||
attributes: { exclude: ['refresh_token_hash'] },
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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 trustedDevice.revokeBySessionId(session.session_id);
|
||||
|
||||
logActivity(req.user.user_id, 'terminate_session', { entityType: 'session', entityId: session.session_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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
const getUserFieldValuesBase = getFieldValues(mdl_Users, "USER", {
|
||||
blockedFields: ["password", "otp_code", "otp_expires_at", "personal_info"],
|
||||
extraDateFields: ["modifiedAt", "ban_expires_at"],
|
||||
allowJsonb: true,
|
||||
selfJoin: true,
|
||||
});
|
||||
|
||||
// "groups" isn't a Users model attribute, so the generic getFieldValues()
|
||||
// helper can't resolve it — special-case it here, delegate everything else.
|
||||
exports.getUserFieldValues = async (req, res) => {
|
||||
if (req.query.field === 'groups') {
|
||||
const groups = await mdl_UserGroups.findAll({
|
||||
attributes: [['group_id', 'value'], ['name', 'label']],
|
||||
order: [['name', 'ASC']],
|
||||
raw: true,
|
||||
});
|
||||
return R.success(res, 'Field values retrieved.', groups);
|
||||
}
|
||||
|
||||
return getUserFieldValuesBase(req, res);
|
||||
};
|
||||
|
||||
// ─── ARCHIVED ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── GET ARCHIVED USERS ───────────────────────────────────────────────────────
|
||||
exports.getArchivedUsers = async (req, res) => {
|
||||
try {
|
||||
const groupIds = extractGroupsFilter(req);
|
||||
const archivedWhere = { deletedAt: { [Op.ne]: null }, is_active: false };
|
||||
const groupsWhere = groupsExistsWhere(groupIds);
|
||||
|
||||
const result = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
computedAttributes: userComputed,
|
||||
context: 'archived',
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
findOptions: {
|
||||
paranoid: false,
|
||||
where: groupsWhere ? { [Op.and]: [archivedWhere, groupsWhere] } : archivedWhere,
|
||||
include: [{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
through: { attributes: [] },
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Archived users retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ARCHIVED USERS]', err);
|
||||
return R.error(res, 'Could not retrieve archived users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET USER ACHIEVEMENTS ────────────────────────────────────────────────────
|
||||
|
||||
exports.getUserAchievements = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const user = await mdl_Users.findByPk(id, { attributes: ['user_id'] });
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const achievements = await mdl_Achievements.findAll({
|
||||
where: { user_id: id },
|
||||
order: [['granted_at', 'DESC']],
|
||||
});
|
||||
|
||||
return R.success(res, 'Achievements retrieved.', achievements);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USER ACHIEVEMENTS]', err);
|
||||
return R.error(res, 'Could not retrieve achievements.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BAN USER ─────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.banUser = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (Number(id) === req.user.user_id)
|
||||
return R.error(res, 'You cannot ban your own account.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.is_banned) return R.error(res, 'User is already banned.', 400);
|
||||
|
||||
const { reason, ban_type, expires_at } = req.body;
|
||||
|
||||
if (!reason?.trim()) return R.error(res, 'Ban reason is required.', 400);
|
||||
if (!['temporary', 'permanent'].includes(ban_type))
|
||||
return R.error(res, 'Invalid ban type. Must be "temporary" or "permanent".', 400);
|
||||
if (ban_type === 'temporary' && !expires_at)
|
||||
return R.error(res, 'Expiry date is required for temporary bans.', 400);
|
||||
if (ban_type === 'temporary' && new Date(expires_at) <= new Date())
|
||||
return R.error(res, 'Expiry date must be in the future.', 400);
|
||||
|
||||
const banExpiresAt = ban_type === 'temporary' ? new Date(expires_at) : null;
|
||||
|
||||
await sequelize.transaction(async (t) => {
|
||||
await mdl_UserBans.create({
|
||||
user_id: id,
|
||||
banned_by: req.user.user_id,
|
||||
reason: reason.trim(),
|
||||
ban_type,
|
||||
banned_at: new Date(),
|
||||
expires_at: banExpiresAt,
|
||||
}, { transaction: t });
|
||||
|
||||
await user.update({ is_banned: true, ban_expires_at: banExpiresAt }, { transaction: t });
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: id }, transaction: t }
|
||||
);
|
||||
await trustedDevice.revokeAllForUser(id);
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'ban_user', {
|
||||
entityType: 'user',
|
||||
entityId: Number(id),
|
||||
details: { reason: reason.trim(), ban_type, expires_at: banExpiresAt },
|
||||
});
|
||||
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
type: 'BANNED',
|
||||
data: {
|
||||
name: user.personal_info?.name?.full_name ?? 'User',
|
||||
email: user.email,
|
||||
date: fmtDate(new Date()),
|
||||
reason: reason.trim(),
|
||||
ban_type,
|
||||
},
|
||||
}).catch((err) => console.error('[ADMIN][BAN USER] Email failed:', err));
|
||||
|
||||
return R.success(res, 'User banned successfully.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BAN USER]', err);
|
||||
return R.error(res, 'Could not ban user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UNBAN USER ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.unbanUser = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const user = await mdl_Users.findByPk(id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (!user.is_banned) return R.error(res, 'User is not currently banned.', 400);
|
||||
|
||||
const { lift_reason } = req.body;
|
||||
|
||||
const activeBan = await mdl_UserBans.findOne({
|
||||
where: { user_id: id, is_lifted: false },
|
||||
order: [['banned_at', 'DESC']],
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (t) => {
|
||||
if (activeBan) {
|
||||
await activeBan.update({
|
||||
is_lifted: true,
|
||||
lifted_at: new Date(),
|
||||
lifted_by: req.user.user_id,
|
||||
lift_reason: lift_reason?.trim() || null,
|
||||
}, { transaction: t });
|
||||
}
|
||||
|
||||
await user.update({ is_banned: false, ban_expires_at: null }, { transaction: t });
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'unban_user', { entityType: 'user', entityId: Number(id) });
|
||||
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
type: 'BAN_LIFTED',
|
||||
data: {
|
||||
name: user.personal_info?.name?.full_name ?? 'User',
|
||||
email: user.email,
|
||||
date: fmtDate(new Date()),
|
||||
},
|
||||
}).catch((err) => console.error('[ADMIN][UNBAN USER] Email failed:', err));
|
||||
|
||||
return R.success(res, 'User unbanned successfully.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UNBAN USER]', err);
|
||||
return R.error(res, 'Could not unban user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── MAKE ADMIN ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.makeAdmin = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (Number(id) === req.user.user_id)
|
||||
return R.error(res, 'You cannot change your own role.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.acc_type === 'admin')
|
||||
return R.error(res, 'User is already an administrator.', 400);
|
||||
|
||||
await user.update({ acc_type: 'admin', updatedBy: req.user.user_id });
|
||||
|
||||
logActivity(req.user.user_id, 'make_admin', { entityType: 'user', entityId: Number(id) });
|
||||
|
||||
const fullName = user.personal_info?.name?.full_name ?? 'User';
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
type: 'MADE_ADMIN',
|
||||
data: { name: fullName.split(' ')[0], email: user.email },
|
||||
}).catch((err) => console.error('[ADMIN][MAKE ADMIN] Email failed:', err));
|
||||
|
||||
return R.success(res, 'User promoted to Administrator.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][MAKE ADMIN]', err);
|
||||
return R.error(res, 'Could not update user role.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DEMOTE ADMIN ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.demoteAdmin = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (Number(id) === req.user.user_id)
|
||||
return R.error(res, 'You cannot change your own role.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.acc_type !== 'admin')
|
||||
return R.error(res, 'User is not an administrator.', 400);
|
||||
|
||||
await user.update({ acc_type: 'user', updatedBy: req.user.user_id });
|
||||
|
||||
logActivity(req.user.user_id, 'demote_admin', { entityType: 'user', entityId: Number(id) });
|
||||
|
||||
const fullName = user.personal_info?.name?.full_name ?? 'User';
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
type: 'DEMOTED_ADMIN',
|
||||
data: { name: fullName.split(' ')[0], email: user.email },
|
||||
}).catch((err) => console.error('[ADMIN][DEMOTE ADMIN] Email failed:', err));
|
||||
|
||||
return R.success(res, 'Administrator access removed.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DEMOTE ADMIN]', err);
|
||||
return R.error(res, 'Could not update user role.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK BAN ─────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkBanUsers = async (req, res) => {
|
||||
try {
|
||||
const { ids, reason, ban_type, expires_at } = 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 ban your own account.', 400);
|
||||
if (!reason?.trim()) return R.error(res, 'Ban reason is required.', 400);
|
||||
if (!['temporary', 'permanent'].includes(ban_type))
|
||||
return R.error(res, 'Invalid ban type.', 400);
|
||||
if (ban_type === 'temporary' && !expires_at)
|
||||
return R.error(res, 'Expiry date is required for temporary bans.', 400);
|
||||
if (ban_type === 'temporary' && new Date(expires_at) <= new Date())
|
||||
return R.error(res, 'Expiry date must be in the future.', 400);
|
||||
|
||||
const users = await mdl_Users.findAll({ where: { user_id: ids } });
|
||||
if (!users.length) return R.error(res, 'No users found.', 404);
|
||||
|
||||
const unbannedUsers = users.filter((u) => !u.is_banned);
|
||||
if (!unbannedUsers.length)
|
||||
return R.error(res, 'All selected users are already banned.', 400);
|
||||
|
||||
const targetIds = unbannedUsers.map((u) => u.user_id);
|
||||
const banExpiresAt = ban_type === 'temporary' ? new Date(expires_at) : null;
|
||||
const now = new Date();
|
||||
|
||||
await sequelize.transaction(async (t) => {
|
||||
await mdl_UserBans.bulkCreate(
|
||||
targetIds.map((uid) => ({
|
||||
user_id: uid,
|
||||
banned_by: req.user.user_id,
|
||||
reason: reason.trim(),
|
||||
ban_type,
|
||||
banned_at: now,
|
||||
expires_at: banExpiresAt,
|
||||
})),
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
await mdl_Users.update(
|
||||
{ is_banned: true, ban_expires_at: banExpiresAt },
|
||||
{ where: { user_id: targetIds }, transaction: t }
|
||||
);
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: now.toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: targetIds }, transaction: t }
|
||||
);
|
||||
await trustedDevice.revokeAllForUser(targetIds);
|
||||
});
|
||||
|
||||
const dateStr = fmtDate(new Date());
|
||||
unbannedUsers.forEach((u) => {
|
||||
sendEmail({
|
||||
to: u.email,
|
||||
type: 'BANNED',
|
||||
data: {
|
||||
name: u.personal_info?.name?.full_name ?? 'User',
|
||||
email: u.email,
|
||||
date: dateStr,
|
||||
reason: reason.trim(),
|
||||
ban_type,
|
||||
},
|
||||
}).catch((err) => console.error('[ADMIN][BULK BAN] Email failed:', u.email, err));
|
||||
});
|
||||
|
||||
return R.success(res, `${targetIds.length} user(s) banned successfully.`, {
|
||||
banned_ids: targetIds,
|
||||
skipped_ids: ids.filter((id) => !targetIds.includes(Number(id))),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK BAN USERS]', err);
|
||||
return R.error(res, 'Could not ban users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET USER BAN HISTORY ─────────────────────────────────────────────────────
|
||||
|
||||
exports.getUserBans = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const user = await mdl_Users.findByPk(id, { attributes: ['user_id'] });
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const bans = await mdl_UserBans.findAll({
|
||||
where: { user_id: id },
|
||||
include: [
|
||||
{ model: mdl_Users, as: 'banner', attributes: ['user_id', 'email', 'personal_info'] },
|
||||
{ model: mdl_Users, as: 'lifter', attributes: ['user_id', 'email', 'personal_info'] },
|
||||
],
|
||||
order: [['banned_at', 'DESC']],
|
||||
});
|
||||
|
||||
return R.success(res, 'Ban history retrieved.', bans);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USER BANS]', err);
|
||||
return R.error(res, 'Could not retrieve ban history.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK UNBAN ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkUnbanUsers = async (req, res) => {
|
||||
try {
|
||||
const { ids, lift_reason } = req.body;
|
||||
|
||||
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 } });
|
||||
if (!users.length) return R.error(res, 'No users found.', 404);
|
||||
|
||||
const bannedUsers = users.filter((u) => u.is_banned);
|
||||
if (!bannedUsers.length)
|
||||
return R.error(res, 'None of the selected users are currently banned.', 400);
|
||||
|
||||
const targetIds = bannedUsers.map((u) => u.user_id);
|
||||
const now = new Date();
|
||||
|
||||
await sequelize.transaction(async (t) => {
|
||||
await mdl_UserBans.update(
|
||||
{
|
||||
is_lifted: true,
|
||||
lifted_at: now,
|
||||
lifted_by: req.user.user_id,
|
||||
lift_reason: lift_reason?.trim() || null,
|
||||
},
|
||||
{ where: { user_id: targetIds, is_lifted: false }, transaction: t }
|
||||
);
|
||||
|
||||
await mdl_Users.update(
|
||||
{ is_banned: false, ban_expires_at: null },
|
||||
{ where: { user_id: targetIds }, transaction: t }
|
||||
);
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_unban_users', {
|
||||
entityType: 'user',
|
||||
details: { unban_ids: targetIds },
|
||||
});
|
||||
|
||||
const dateStr = fmtDate(now);
|
||||
bannedUsers.forEach((u) => {
|
||||
sendEmail({
|
||||
to: u.email,
|
||||
type: 'BAN_LIFTED',
|
||||
data: {
|
||||
name: u.personal_info?.name?.full_name ?? 'User',
|
||||
email: u.email,
|
||||
date: dateStr,
|
||||
},
|
||||
}).catch((err) => console.error('[ADMIN][BULK UNBAN] Email failed:', u.email, err));
|
||||
});
|
||||
|
||||
return R.success(res, `${targetIds.length} user(s) unbanned successfully.`, {
|
||||
unbanned_ids: targetIds,
|
||||
skipped_ids: ids.filter((id) => !targetIds.includes(Number(id))),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK UNBAN USERS]', err);
|
||||
return R.error(res, 'Could not unban users.', 500);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user