chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
@@ -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);
}
};