ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:06:58 +08:00
parent bf48c95467
commit 439bb33f77
189 changed files with 17559 additions and 686 deletions
@@ -0,0 +1,357 @@
// 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 R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes");
const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util');
const { Op } = require('sequelize');
// ─── Helpers ──────────────────────────────────────────────────────────────────
const notDeleted = { deletedAt: null };
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"];
// ─── 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";
}
const ALLOWED_CTA_VARIANTS = ["default", "outline"];
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(),
// First CTA defaults to "default" (primary), second to "outline" — but
// an explicit, valid variant from the client always wins.
variant: ALLOWED_CTA_VARIANTS.includes(c.variant) ? c.variant : (i === 0 ? "default" : "outline"),
}));
}
async function applyAdvertisementFields(advertisement, body) {
if (body.type !== undefined) {
if (!ALLOWED_TYPES.includes(body.type)) {
const err = new Error(`Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`);
err.status = 400;
throw err;
}
advertisement.type = body.type;
}
// status is intentionally NOT settable here — it's derived via deriveStatus()
// right before save, based on is_active + start_date/end_date.
if (body.badge_label !== undefined) advertisement.badge_label = body.badge_label;
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.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 asset 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"].includes(body.size)) {
const err = new Error(`Invalid size. Must be one of: sm, md, lg`);
err.status = 400;
throw err;
}
advertisement.size = body.size || null;
}
// Recompute status now that is_active/start_date/end_date are all up to date
advertisement.status = deriveStatus(advertisement);
}
// ─── GET ALL ──────────────────────────────────────────────────────────────────
exports.getAdvertisements = async (req, res) => {
try {
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: ["asset_id", "display_name", "file_url", "thumbnail_url"],
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 = result.data.map((row) => ({ ...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: ["asset_id", "display_name", "file_url", "thumbnail_url"], 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.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 { type, createdBy } = req.body;
if (!type) return R.error(res, "type is required.", 400);
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400);
if (!createdBy) return R.error(res, "createdBy is required.", 400);
const t = await sequelize.transaction();
try {
const advertisement = await Advertisement.build({ type, createdBy });
await applyAdvertisementFields(advertisement, req.body);
await advertisement.save({ transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { 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);
}
};
// ─── 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);
await advertisement.update({ deletedBy: req.body.deletedBy ?? null });
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);
await Advertisement.update({ deletedBy: deletedBy ?? null }, { where: { advertisement_id: { [Op.in]: activeIds } } });
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: "list",
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");
+253 -238
View File
@@ -5,15 +5,16 @@ const crypto = require("crypto");
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 { extractVideoMeta } = require("../../services/ffprobe.service");
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes, } = require("../../models/assets/assets.attributes");
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, Sequelize } = require('sequelize');
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
const { Op } = require('sequelize');
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -22,9 +23,8 @@ const notDeleted = { deletedAt: null };
function resolveFileType(mimeType = "") {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("application/") || mimeType.startsWith("text/"))
return "document";
return "image";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
}
function resolveExtension(originalName = "") {
@@ -41,31 +41,64 @@ function resolveResolution(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";
if (h >= 720) return "720p";
if (h >= 480) return "480p";
if (h >= 360) return "360p";
if (h >= 240) return "240p";
return `${width}x${height}`;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
// ─── 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
}
async function uploadToAlbum(file, ownerType) {
// ─── 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);
}
}
}
// ─── uploadToProvider ─────────────────────────────────────────────────────────
//
// Uploads a single file to the resolved provider.
// Returns { file_url, storage_key, mime_type, extension, checksum, file_type }
//
async function uploadToProvider(file, ownerType, storageProvider) {
const mime_type = file.mimetype;
const extension = resolveExtension(file.originalname);
const checksum = resolveChecksum(file.buffer);
const checksum = resolveChecksum(file.buffer);
const file_type = resolveFileType(mime_type);
const chibiResult = await chibi.uploadFile({
buffer: file.buffer,
const svc = getProvider(storageProvider);
const result = await svc.uploadFile({
buffer: file.buffer,
originalname: file.originalname,
mimetype: mime_type,
mimetype: mime_type,
ownerType,
});
return {
file_url: chibiResult.url,
chibi_uuid: chibiResult.uuid,
file_url: result.url,
storage_key: result.uuid, // chibisafe UUID or S3 key — both stored as storage_key in DB
mime_type,
extension,
checksum,
@@ -73,62 +106,64 @@ async function uploadToAlbum(file, ownerType) {
};
}
// ─── 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;
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.chibi_uuid;
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.chibi_uuid;
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 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.width = parsedWidth;
asset.height = parsedHeight;
asset.resolution = resolveResolution(parsedWidth, parsedHeight);
}
}
}
}
async function deleteOldFile(asset, oldStorageKey, newUuid) {
if (asset.storage_provider !== "chibisafe") return;
if (!oldStorageKey || oldStorageKey === newUuid) return;
// ─── deleteOldFile ────────────────────────────────────────────────────────────
async function deleteOldFile(storageProvider, oldStorageKey, newKey) {
if (!oldStorageKey || oldStorageKey === newKey) return;
const svc = getProvider(storageProvider);
if (!svc) return;
try {
await chibi.deleteFile(oldStorageKey);
await svc.deleteFile(oldStorageKey);
} catch (err) {
console.warn("[ASSET][REPLACE FILE] Old file cleanup failed:", err.message);
console.warn(`[ASSET][CLEANUP] Old file cleanup failed for "${oldStorageKey}":`, err.message);
}
}
/**
* Best-effort cleanup of Chibisafe files after a failed transaction.
* Never throws — the original error is what matters.
*/
async function rollbackChibiUploads(uuids = []) {
for (const uuid of uuids) {
if (!uuid) continue;
try {
await chibi.deleteFile(uuid);
} catch (cleanupErr) {
console.error(`[ASSET][ROLLBACK] Failed to delete Chibisafe file ${uuid}:`, cleanupErr.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;
}
// ─── GET ALL ──────────────────────────────────────────────────────────────────
@@ -141,11 +176,9 @@ exports.getAssets = async (req, res) => {
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Asset' },
findOptions: {
where: { deletedAt: null },
},
findOptions: { where: { deletedAt: null } },
});
result.data = result.data.map(redactS3Url);
return R.success(res, "Assets retrieved.", result);
} catch (err) {
console.error("[ASSET][GET ALL]", err);
@@ -158,48 +191,35 @@ exports.getAssets = async (req, res) => {
exports.getAsset = async (req, res) => {
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") {
return R.error(res, "Invalid asset ID.", 400);
}
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
const asset = await Asset.findOne({
where: { asset_id: assetId, ...notDeleted },
attributes: { exclude: ["storage_key", "storage_bucket"] },
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",
},
{ 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 (!asset) return R.error(res, "Asset not found.", 404);
// ── Flatten creator / updater name from personal_info JSONB ──────────────
const json = asset.toJSON();
if (json.creator) {
json.creator = {
user_id: json.creator.user_id,
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,
user_id: json.updater.user_id,
full_name: json.updater.personal_info?.name?.full_name ?? null,
};
}
redactS3Url(json);
return R.success(res, "Asset retrieved.", { data: json });
} catch (err) {
console.error("[ASSET][GET ONE]", err);
@@ -212,31 +232,27 @@ exports.getAsset = async (req, res) => {
// ┌─────────────────────────────────────────────────────────────────────────┐
// │ TRANSACTION STRATEGY │
// │ │
// │ Phase 1 — SLOW WORK (no transaction, no DB connection held): │
// │ Phase 1 — SLOW WORK (outside transaction): │
// │ • Input validation │
// │ • ffprobe metadata extraction │
// │ • Chibisafe file upload → track UUID for rollback │
// │ • Chibisafe thumb upload → track UUID for rollback │
// │ • Provider upload (chibi or s3) → track for rollback │
// │ • Thumbnail upload → track for rollback │
// │ │
// │ Phase 2 — FAST WORK (transaction open for milliseconds only): │
// │ • BEGIN transaction │
// │ • Asset.create() │
// │ • COMMIT │
// │ Phase 2 — FAST WORK (transaction open milliseconds only): │
// │ • BEGIN → Asset.create() → COMMIT │
// │ │
// │ On any Phase 2 error: │
// │ On Phase 2 error: │
// │ • ROLLBACK transaction │
// │ • deleteFile() each tracked Chibisafe UUID (cleanup orphans) │
// │ • rollbackUploads([{ key, provider }]) to clean orphans │
// └─────────────────────────────────────────────────────────────────────────┘
//
// Expects multer.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }])
exports.uploadAsset = async (req, res) => {
const uploadedChibiUuids = [];
const uploadedFiles = []; // [{ key, provider }]
try {
// ── Phase 1a: Validate inputs ─────────────────────────────────────────────
// ── Phase 1a: Validate ────────────────────────────────────────────────────
const file = req.files?.file?.[0];
const file = req.files?.file?.[0];
const thumbFile = req.files?.thumbnail?.[0];
if (!file) return R.error(res, "No file uploaded.", 400);
@@ -244,111 +260,116 @@ exports.uploadAsset = async (req, res) => {
const {
display_name,
description,
is_public = false,
storage_provider = "local",
is_public = false,
storage_provider = "chibisafe",
storage_bucket,
storage_key,
createdBy,
} = req.body;
if (!createdBy) {
return R.error(res, "createdBy is required.", 400);
}
if (!createdBy) return R.error(res, "createdBy is required.", 400);
const mime_type = file.mimetype;
const file_type = resolveFileType(mime_type);
const extension = resolveExtension(file.originalname);
const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
const mime_type = file.mimetype;
const file_type = resolveFileType(mime_type);
const extension = resolveExtension(file.originalname);
const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
const usesProvider = ["chibisafe", "s3"].includes(storage_provider);
if (file_type === "video" && !thumbFile) {
return R.error(res, "A thumbnail image is required for video uploads. Include it as the 'thumbnail' field.", 400);
return R.error(res, "A thumbnail image is required for video uploads.", 400);
}
if (storage_provider === "chibisafe" && !file.buffer) {
return R.error(res, "File buffer is required for Chibisafe uploads. Ensure multer uses memoryStorage.", 400);
if (usesProvider && !file.buffer) {
return R.error(res, "File buffer is required. Ensure multer uses memoryStorage.", 400);
}
// ── Phase 1b: Upload main file to Chibisafe ───────────────────────────────
// ── Phase 1b: Upload main file ────────────────────────────────────────────
let file_url = null;
let chibi_uuid = null;
let file_url = null;
let storage_key_resolved = null;
if (storage_provider === "chibisafe") {
const chibiResult = await chibi.uploadFile({
buffer: file.buffer,
if (usesProvider) {
const svc = getProvider(storage_provider);
const result = await svc.uploadFile({
buffer: file.buffer,
originalname: file.originalname,
mimetype: mime_type,
mimetype: mime_type,
ownerType: file_type,
});
file_url = chibiResult.url;
chibi_uuid = chibiResult.uuid;
uploadedChibiUuids.push(chibi_uuid);
file_url = result.url;
storage_key_resolved = result.uuid;
uploadedFiles.push({ key: storage_key_resolved, provider: storage_provider });
} else {
file_url = storage_provider === "local"
? `/uploads/${file.filename}`
: req.body.file_url;
if (!file_url) {
return R.error(res, "file_url is required for non-local storage.", 400);
}
if (!file_url) return R.error(res, "file_url is required for non-local storage.", 400);
}
// ── Phase 1c: ffprobe + thumbnail upload ──────────────────────────────────
// ── Phase 1c: ffprobe + thumbnail ─────────────────────────────────────────
let width = null;
let height = null;
let resolution = null;
let duration = null;
let frame_rate = null;
let bitrate = null;
let video_codec = null;
let audio_codec = null;
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") {
const meta = await extractVideoMeta({
buffer: file.buffer,
extension: extension || "mp4",
});
width = meta.width;
height = meta.height;
resolution = meta.resolution;
duration = meta.duration;
frame_rate = meta.frame_rate;
bitrate = meta.bitrate;
const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || "mp4" });
width = meta.width;
height = meta.height;
resolution = meta.resolution;
duration = meta.duration;
frame_rate = meta.frame_rate;
bitrate = meta.bitrate;
video_codec = meta.video_codec;
audio_codec = meta.audio_codec;
if (storage_provider === "chibisafe") {
if (!thumbFile.buffer) {
await rollbackChibiUploads(uploadedChibiUuids);
return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400);
if (usesProvider) {
if (!thumbFile?.buffer) {
await rollbackUploads(uploadedFiles);
return R.error(res, "Thumbnail buffer is required.", 400);
}
const baseName = file.originalname.replace(/\.[^.]+$/, "");
const thumbResult = await chibi.uploadFile({
buffer: thumbFile.buffer,
const baseName = file.originalname.replace(/\.[^.]+$/, "");
const svc = getProvider(storage_provider);
const thumbResult = await svc.uploadFile({
buffer: thumbFile.buffer,
originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`,
mimetype: thumbFile.mimetype,
ownerType: "thumbnail",
mimetype: thumbFile.mimetype,
ownerType: "thumbnail",
});
thumbnail_url = thumbResult.url;
uploadedChibiUuids.push(thumbResult.uuid);
uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider });
} else {
thumbnail_url = thumbFile.filename
? `/uploads/${thumbFile.filename}`
: null;
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
}
} else if (file_type === "audio" && thumbFile) {
if (usesProvider) {
if (!thumbFile.buffer) {
await rollbackUploads(uploadedFiles);
return R.error(res, "Thumbnail buffer is required.", 400);
}
const baseName = file.originalname.replace(/\.[^.]+$/, "");
const svc = getProvider(storage_provider);
const thumbResult = await svc.uploadFile({
buffer: thumbFile.buffer,
originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`,
mimetype: thumbFile.mimetype,
ownerType: "thumbnail",
});
thumbnail_url = thumbResult.url;
uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider });
} else {
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
}
} else {
const parsedWidth = req.body.width ? parseInt(req.body.width) : null;
const parsedWidth = req.body.width ? parseInt(req.body.width) : null;
const parsedHeight = req.body.height ? parseInt(req.body.height) : null;
width = parsedWidth;
height = parsedHeight;
width = parsedWidth;
height = parsedHeight;
resolution = resolveResolution(parsedWidth, parsedHeight);
}
@@ -357,10 +378,10 @@ exports.uploadAsset = async (req, res) => {
const t = await sequelize.transaction();
try {
const asset = await Asset.create({
original_name: file.originalname,
display_name: display_name || file.originalname,
original_name: file.originalname,
display_name: display_name || file.originalname,
file_url,
file_size: file.size,
file_size: file.size,
mime_type,
extension,
checksum,
@@ -376,29 +397,87 @@ exports.uploadAsset = async (req, res) => {
thumbnail_url,
description,
storage_provider,
storage_bucket: storage_bucket || null,
storage_key: chibi_uuid || storage_key || file.filename,
storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null,
storage_key: storage_key_resolved || storage_key || file.filename || null,
is_public,
createdBy,
}, { transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
return R.success(res, "Asset uploaded.", { data: asset }, 201);
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection already gone */ }
await rollbackChibiUploads(uploadedChibiUuids);
try { await t.rollback(); } catch { /* connection gone */ }
await rollbackUploads(uploadedFiles);
throw dbErr;
}
} 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);
}
};
if (err.status) {
return R.error(res, err.message, err.status, { detail: err.chibiBody });
// ─── 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 asset ID.", 400);
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "Asset not found.", 404);
const file = req.files?.file?.[0] ?? req.file ?? null;
const isVideo = asset.file_type === "video";
const isDocument = asset.file_type === "document";
if (isDocument && file) return R.error(res, "Document files cannot be replaced.", 400);
if (isVideo && file && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400);
if (file && !file.buffer) return R.error(res, "File buffer is required.", 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 (file && usesProvider) {
const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype);
uploaded = await uploadToProvider(file, ownerType, storageProvider);
newUpload = { key: uploaded.storage_key, provider: storageProvider };
}
// ── Phase 2: DB update ────────────────────────────────────────────────────
const t = await sequelize.transaction();
try {
await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, 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);
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset 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);
}
};
@@ -415,7 +494,7 @@ exports.archiveAsset = async (req, res) => {
await asset.update({ deletedBy: req.body.deletedBy ?? null });
await asset.destroy();
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset archived.");
} catch (err) {
console.error("[ASSET][ARCHIVE]", err);
@@ -435,12 +514,10 @@ exports.archiveAssets = async (req, res) => {
const activeIds = assets.map((a) => a.asset_id);
await Asset.update(
{ deletedBy: deletedBy ?? null },
{ where: { asset_id: { [Op.in]: activeIds } } }
);
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} asset(s) archived.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
@@ -457,16 +534,13 @@ 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, "Asset not found.", 404);
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
if (!asset) return R.error(res, "Asset not found.", 404);
if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400);
await asset.restore();
await asset.update({ deletedBy: null });
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset restored.", { data: asset });
} catch (err) {
console.error("[ASSET][RESTORE]", err);
@@ -481,10 +555,7 @@ exports.restoreAssets = async (req, res) => {
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,
});
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
if (!assets.length) return R.error(res, "No assets found.", 404);
const archivedAssets = assets.filter((a) => a.deletedAt);
@@ -493,11 +564,9 @@ exports.restoreAssets = async (req, res) => {
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 }
);
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false });
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} asset(s) restored.`, {
restored_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
@@ -508,58 +577,7 @@ exports.restoreAssets = async (req, res) => {
}
};
exports.updateAsset = async (req, res) => {
let newChibiUuid = null;
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "Asset not found.", 404);
const file = req.files?.file?.[0] ?? req.file ?? null;
const isVideo = asset.file_type === "video";
const isDocument = asset.file_type === "document";
if (isDocument && file) return R.error(res, "Document files cannot be replaced.", 400);
if (isVideo && file && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400);
if (file && !file.buffer) return R.error(res, "File buffer is required.", 400);
// ── Phase 1: Upload ───────────────────────────────────────────────────────
let uploaded = null;
let oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key;
if (file && asset.storage_provider === "chibisafe") {
const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype);
uploaded = await uploadToAlbum(file, ownerType);
newChibiUuid = uploaded.chibi_uuid;
}
// ── Phase 2: DB update ────────────────────────────────────────────────────
const t = await sequelize.transaction();
try {
await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, req.body);
await asset.save({ transaction: t });
await t.commit();
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
if (newChibiUuid) await rollbackChibiUploads([newChibiUuid]);
throw dbErr;
}
// ── Phase 3: Cleanup ──────────────────────────────────────────────────────
if (uploaded) await deleteOldFile(asset, oldStorageKey, newChibiUuid);
return R.success(res, "Asset updated.", { data: asset });
} catch (err) {
if (newChibiUuid) await rollbackChibiUploads([newChibiUuid]);
console.error("[ASSET][REPLACE FILE]", err);
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
exports.getArchivedAssets = async (req, res) => {
try {
@@ -569,12 +587,9 @@ exports.getArchivedAssets = async (req, res) => {
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Asset' },
findOptions: {
paranoid: false,
where: { deletedAt: { [Op.ne]: null } },
},
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
});
result.data = result.data.map(redactS3Url);
return R.success(res, "Archived assets retrieved.", result);
} catch (err) {
console.error("[ASSET][GET ARCHIVED]", err);
@@ -0,0 +1,89 @@
'use strict';
const mdl_Category = require('../../models/courses/categories.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const slugify = (str) =>
str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
exports.getCategories = async (req, res) => {
try {
const rows = await mdl_Category.findAll({ order: [['name', 'ASC']], paranoid: false });
return R.success(res, 'Categories retrieved.', rows);
} 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 });
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 });
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.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);
await row.restore();
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);
}
};
@@ -0,0 +1,196 @@
/***********************************************************************************************************************************************************************
* 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 } = require('../../models/courses/courses.associations');
const mdl_Users = require('../../models/users/users.mdl');
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)
const [units, allLessons] = await Promise.all([
Unit.findAll({
where: { course_id: courseId, ...notDeleted },
attributes: ['unit_id', 'uuid'],
}),
Lesson.findAll({
include: [{
model: Unit,
as: 'unit',
where: { course_id: courseId, ...notDeleted },
attributes: [],
required: true,
}],
where: { ...notDeleted },
attributes: ['lesson_id', 'uuid'],
}),
]);
const units_total = units.length;
const lessons_total = allLessons.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]));
// 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 = Object.values(summaryMap).map((entry) => {
const u = userMap[entry.user_id];
return {
...entry,
user: {
email: u?.email ?? null,
full_name: u?.personal_info?.name?.full_name ?? null,
avatar_url: u?.personal_info?.avatar?.url ?? 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',
};
});
// 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 [units, progressRows] = await Promise.all([
Unit.findAll({
where: { course_id: courseId, ...notDeleted },
attributes: ['unit_id', 'uuid', 'title', 'order_index'],
include: [{
model: Lesson,
as: 'lessons',
where: notDeleted,
required: false,
attributes: ['lesson_id', 'uuid', 'title', 'order_index'],
}],
order: [
['order_index', 'ASC'],
[{ model: Lesson, as: 'lessons' }, 'order_index', 'ASC'],
],
}),
CourseReadingProgress.findAll({
where: { course_id: courseId, user_id: userId },
attributes: ['reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'],
}),
]);
// 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);
}
};
+197 -9
View File
@@ -10,15 +10,17 @@ const { syncJunction } = require("../../utils/courses/junction.util");
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util');
// ── Models ────────────────────────────────────────────────────────────────────
const {
Course, CourseProduct, CourseRole, CourseProductCategory: CourseProductCat,
Course, CourseProductCategory: CourseProductCat,
Unit, Lesson, LessonPage,
CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption,
CourseInstructor,
} = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl");
@@ -94,7 +96,7 @@ exports.createCourse = async (req, res) => {
title, description, order_index,
course_code, level, subscription,
objectives = [],
product_ids = [], role_ids = [], category_ids = [],
category_ids = [],
createdBy,
} = req.body;
@@ -112,11 +114,10 @@ exports.createCourse = async (req, res) => {
}, { transaction: t });
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t);
await syncJunction(CourseProduct, course.course_id, product_ids, "product_id", t);
await syncJunction(CourseRole, course.course_id, role_ids, "role_id", t);
await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t);
await t.commit();
logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title } });
return R.success(res, "Course created.", { data: course }, 201);
} catch (err) {
await t.rollback();
@@ -135,7 +136,7 @@ exports.updateCourse = async (req, res) => {
const {
title, description, order_index,
course_code, level, subscription,
objectives, product_ids, role_ids, category_ids,
objectives, category_ids,
updatedBy,
} = req.body;
@@ -149,11 +150,10 @@ exports.updateCourse = async (req, res) => {
await course.save({ transaction: t });
if (objectives !== undefined) await syncObjectivesUpdate(CourseObjective, "course_id", courseId, objectives, t);
if (product_ids !== undefined) await syncJunction(CourseProduct, courseId, product_ids, "product_id", t);
if (role_ids !== undefined) await syncJunction(CourseRole, courseId, role_ids, "role_id", t);
if (category_ids !== undefined) await syncJunction(CourseProductCat, courseId, category_ids, "category_id", t);
await t.commit();
logActivity(req.user?.user_id, 'update_course', { entityType: 'course', entityId: Number(courseId), details: { title: course.title } });
return R.success(res, "Course updated.", { data: course });
} catch (err) {
await t.rollback();
@@ -169,6 +169,7 @@ exports.archiveCourse = async (req, res) => {
const record = await archiveOne(Course, { course_id: courseId, ...notDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Course not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'archive_course', { entityType: 'course', entityId: Number(courseId) });
return R.success(res, "Course archived.");
} catch (err) {
await t.rollback();
@@ -185,6 +186,7 @@ exports.bulkArchiveCourses = async (req, res) => {
const count = await archiveMany(Course, "course_id", ids, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_courses', { entityType: 'course', details: { ids, count } });
return R.success(res, `${count} course${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
@@ -231,6 +233,7 @@ exports.restoreCourse = async (req, res) => {
const record = await restoreOne(Course, { course_id: courseId }, req.user.user_id, t);
if (!record) return R.error(res, "Archived course not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'restore_course', { entityType: 'course', entityId: Number(courseId) });
return R.success(res, "Course restored.", { data: record });
} catch (err) {
await t.rollback();
@@ -247,6 +250,7 @@ exports.bulkRestoreCourses = async (req, res) => {
const count = await restoreMany(Course, "course_id", ids, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_courses', { entityType: 'course', details: { ids, count } });
return R.success(res, `${count} course${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
@@ -304,6 +308,7 @@ exports.syncPrerequisites = async (req, res) => {
}
await t.commit();
logActivity(req.user?.user_id, 'sync_prerequisites', { entityType: 'course', entityId: Number(courseId), details: { count: prerequisites.length } });
return R.success(res, "Prerequisites updated.");
} catch (err) {
await t.rollback();
@@ -380,6 +385,7 @@ exports.createUnit = async (req, res) => {
createdBy: createdBy ?? null,
});
logActivity(req.user?.user_id, 'create_unit', { entityType: 'unit', entityId: unit.unit_id, details: { title: unit.title } });
return R.success(res, "Unit created.", { data: unit }, 201);
} catch (err) {
console.error("[UNIT][CREATE]", err);
@@ -402,6 +408,7 @@ exports.updateUnit = async (req, res) => {
unit.updatedBy = updatedBy ?? 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][UPDATE]", err);
@@ -416,6 +423,7 @@ exports.archiveUnit = async (req, res) => {
const record = await archiveOne(Unit, { unit_id: unitId, course_id: courseId, ...notDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Unit not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'archive_unit', { entityType: 'unit', entityId: Number(unitId) });
return R.success(res, "Unit archived.");
} catch (err) {
await t.rollback();
@@ -436,6 +444,7 @@ exports.bulkArchiveUnits = async (req, res) => {
const count = await archiveMany(Unit, "unit_id", validIds, req.user.user_id, t);
await t.commit();
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();
@@ -491,6 +500,7 @@ exports.restoreUnit = async (req, res) => {
const record = await restoreOne(Unit, { unit_id: unitId, course_id: courseId, ...onlyDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Archived unit not found.", 404);
await t.commit();
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();
@@ -511,6 +521,7 @@ exports.bulkRestoreUnits = async (req, res) => {
const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t);
await t.commit();
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();
@@ -597,6 +608,7 @@ exports.createLesson = async (req, res) => {
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t);
await t.commit();
logActivity(req.user?.user_id, 'create_lesson', { entityType: 'lesson', entityId: lesson.lesson_id, details: { title: lesson.title } });
return R.success(res, "Lesson created.", { data: lesson }, 201);
} catch (err) {
await t.rollback();
@@ -635,6 +647,7 @@ exports.updateLesson = async (req, res) => {
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();
@@ -655,6 +668,7 @@ exports.archiveLesson = async (req, res) => {
if (!record) return R.error(res, "Lesson not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'archive_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
return R.success(res, "Lesson archived.");
} catch (err) {
await t.rollback();
@@ -678,6 +692,7 @@ exports.bulkArchiveLessons = async (req, res) => {
const count = await archiveMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
await t.commit();
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();
@@ -741,6 +756,7 @@ exports.restoreLesson = async (req, res) => {
const record = await restoreOne(Lesson, { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Archived lesson not found.", 404);
await t.commit();
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();
@@ -764,6 +780,7 @@ exports.bulkRestoreLessons = async (req, res) => {
const count = await restoreMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
await t.commit();
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();
@@ -811,6 +828,7 @@ exports.upsertLessonPage = async (req, res) => {
console.error("[LESSON 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.",
@@ -871,6 +889,7 @@ exports.createQuiz = async (req, res) => {
createdBy: createdBy ?? 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("[QUIZ][CREATE]", err);
@@ -894,6 +913,7 @@ exports.updateQuiz = async (req, res) => {
quiz.updatedBy = updatedBy ?? 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("[QUIZ][UPDATE]", err);
@@ -908,6 +928,7 @@ exports.deleteQuiz = async (req, res) => {
const record = await archiveOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...notDeleted }, req.body.deletedBy, 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();
@@ -942,6 +963,7 @@ exports.restoreQuiz = async (req, res) => {
const record = await restoreOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...onlyDeleted }, req.body.restoredBy, 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();
@@ -1034,6 +1056,7 @@ exports.createQuestion = async (req, res) => {
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
logActivity(req.user?.user_id, 'create_question', { entityType: 'question', entityId: created.question_id });
return R.success(res, "Question created.", { data: created }, 201);
} catch (err) {
await t.rollback();
@@ -1081,6 +1104,7 @@ exports.updateQuestion = async (req, res) => {
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
logActivity(req.user?.user_id, 'update_question', { entityType: 'question', entityId: Number(questionId) });
return R.success(res, "Question updated.", { data: updated });
} catch (err) {
await t.rollback();
@@ -1096,6 +1120,7 @@ exports.deleteQuestion = async (req, res) => {
const record = await archiveOne(QuizQuestion, { question_id: questionId, ...notDeleted }, req.body.deletedBy, t);
if (!record) return R.error(res, "Question not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'archive_question', { entityType: 'question', entityId: Number(questionId) });
return R.success(res, "Question archived.");
} catch (err) {
await t.rollback();
@@ -1127,6 +1152,7 @@ exports.restoreQuestion = async (req, res) => {
const record = await restoreOne(QuizQuestion, { question_id: questionId, ...onlyDeleted }, req.body.restoredBy, t);
if (!record) return R.error(res, "Archived question not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'restore_question', { entityType: 'question', entityId: Number(questionId) });
return R.success(res, "Question restored.", { data: record });
} catch (err) {
await t.rollback();
@@ -1152,6 +1178,7 @@ exports.bulkArchiveQuestions = async (req, res) => {
const count = await archiveMany(QuizQuestion, "question_id", validIds, deletedBy, t);
await t.commit();
logActivity(req.user?.user_id, 'bulk_archive_questions', { entityType: 'question', details: { ids: validIds, count } });
return R.success(res, `${count} question${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
@@ -1177,6 +1204,7 @@ exports.bulkRestoreQuestions = async (req, res) => {
const count = await restoreMany(QuizQuestion, "question_id", validIds, restoredBy, t);
await t.commit();
logActivity(req.user?.user_id, 'bulk_restore_questions', { entityType: 'question', details: { ids: validIds, count } });
return R.success(res, `${count} question${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
@@ -1227,10 +1255,11 @@ exports.createAssessment = async (req, res) => {
is_required: is_required ?? false,
passing_score: passing_score ?? 70,
time_limit_minutes: time_limit_minutes ?? null,
max_questions: max_questions ?? null, // ← add
max_questions: max_questions ?? null,
createdBy: createdBy ?? null,
});
logActivity(req.user?.user_id, 'create_assessment', { entityType: 'assessment', entityId: assessment.assessment_id });
return R.success(res, "Assessment created.", { data: assessment }, 201);
} catch (err) {
console.error("[ASSESSMENT][CREATE]", err);
@@ -1257,6 +1286,7 @@ exports.updateAssessment = async (req, res) => {
assessment.updatedBy = updatedBy ?? null;
await assessment.save();
logActivity(req.user?.user_id, 'update_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
return R.success(res, "Assessment updated.", { data: assessment });
} catch (err) {
console.error("[ASSESSMENT][UPDATE]", err);
@@ -1271,6 +1301,7 @@ exports.deleteAssessment = async (req, res) => {
const record = await archiveOne(CourseAssessment, { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, req.body.deletedBy, t);
if (!record) return R.error(res, "Assessment not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'archive_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
return R.success(res, "Assessment archived.");
} catch (err) {
await t.rollback();
@@ -1305,6 +1336,7 @@ exports.restoreAssessment = async (req, res) => {
const record = await restoreOne(CourseAssessment, { assessment_id: assessmentId, course_id: courseId, ...onlyDeleted }, req.body.restoredBy, t);
if (!record) return R.error(res, "Archived assessment not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'restore_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
return R.success(res, "Assessment restored.", { data: record });
} catch (err) {
await t.rollback();
@@ -1315,4 +1347,160 @@ exports.restoreAssessment = async (req, res) => {
exports.getCourseFieldValues = getFieldValues(Course, "COURSE");
exports.getUnitFieldValues = getFieldValues(Unit, "UNIT");
exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON");
exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON");
// ── Flat lists for requirement builder dropdowns ───────────────────────────────
// Returns lightweight id+title arrays (no pagination) used when building task requirements.
exports.getCoursesFlat = async (req, res) => {
try {
const data = await Course.findAll({
where: notDeleted,
attributes: ["uuid", "title"],
order: [["title", "ASC"]],
});
return R.success(res, "Courses retrieved.", data);
} catch (err) {
console.error("[COURSE][GET FLAT]", err);
return R.error(res, "Could not retrieve courses.", 500);
}
};
exports.getUnitsFlat = async (req, res) => {
try {
const rows = await Unit.findAll({
where: notDeleted,
attributes: ["uuid", "title", "order_index"],
include: [{
model: Course,
as: "course",
attributes: ["title"],
}],
order: [
[{ model: Course, as: "course" }, "title", "ASC"],
["order_index", "ASC"],
],
});
const data = rows.map((u) => ({
uuid: u.uuid,
title: u.title,
order_index: u.order_index ?? 0,
course_title: u.course?.title ?? "",
}));
return R.success(res, "Units retrieved.", data);
} catch (err) {
console.error("[UNIT][GET FLAT]", err);
return R.error(res, "Could not retrieve units.", 500);
}
};
exports.getLessonsFlat = async (req, res) => {
try {
const rows = await Lesson.findAll({
where: notDeleted,
attributes: ["uuid", "title", "order_index"],
include: [{
model: Unit,
as: "unit",
attributes: ["title", "order_index"],
include: [{
model: Course,
as: "course",
attributes: ["title"],
}],
}],
order: [
[{ model: Unit, as: "unit" }, { model: Course, as: "course" }, "title", "ASC"],
[{ model: Unit, as: "unit" }, "order_index", "ASC"],
["order_index", "ASC"],
],
});
const data = rows.map((l) => ({
uuid: l.uuid,
title: l.title,
order_index: l.order_index ?? 0,
unit_title: l.unit?.title ?? "",
unit_order: l.unit?.order_index ?? 0,
course_title: l.unit?.course?.title ?? "",
}));
return R.success(res, "Lessons retrieved.", data);
} catch (err) {
console.error("[LESSON][GET FLAT]", err);
return R.error(res, "Could not retrieve lessons.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE INSTRUCTORS
// ══════════════════════════════════════════════════════════════════════════════
exports.getInstructors = async (req, res) => {
try {
const { courseId } = req.params;
const instructors = await CourseInstructor.findAll({
where: { course_id: courseId },
order: [["order_index", "ASC"]],
include: [{
model: mdl_Users,
as: "user",
attributes: ["user_id", "email", "acc_type", "personal_info"],
required: false,
}],
});
return R.success(res, "Instructors retrieved.", { data: instructors });
} catch (err) {
console.error("[INSTRUCTOR][GET]", err);
return R.error(res, "Could not retrieve instructors.", 500);
}
};
exports.syncInstructors = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { instructors = [] } = req.body;
const actor_id = req.user?.user_id ?? null;
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404);
// Validate any linked user_ids are staff or admin
const linkedIds = instructors.map(i => i.user_id).filter(Boolean);
if (linkedIds.length) {
const validUsers = await mdl_Users.findAll({
where: { user_id: linkedIds, acc_type: ["staff", "admin"], deletedAt: null },
attributes: ["user_id"],
});
const validSet = new Set(validUsers.map(u => String(u.user_id)));
const invalid = linkedIds.find(id => !validSet.has(String(id)));
if (invalid) {
await t.rollback();
return R.error(res, `User ${invalid} is not a staff or admin account.`, 422);
}
}
await CourseInstructor.destroy({ where: { course_id: courseId }, transaction: t });
if (instructors.length) {
await CourseInstructor.bulkCreate(
instructors.map((inst, i) => ({
course_id: courseId,
user_id: inst.user_id ?? null,
display_name: inst.display_name,
order_index: inst.order_index ?? i,
created_by: actor_id,
})),
{ transaction: t }
);
}
await t.commit();
logActivity(actor_id, 'sync_instructors', { entityType: 'course', entityId: Number(courseId), details: { count: instructors.length } });
return R.success(res, "Instructors updated.");
} catch (err) {
await t.rollback();
console.error("[INSTRUCTOR][SYNC]", err);
return R.error(res, "Could not update instructors.", 500);
}
};
@@ -0,0 +1,284 @@
# 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)
---
## 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": "...",
"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 |
|-------|------|----------|-------------|
| `type` | string | **Yes** | `hero`, `banner`, `popup`, `sidebar` |
| `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` | `type is required.` |
| `400` | `Invalid type. Must be one of: hero, banner, popup, sidebar` |
| `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. Does not accept `type` once set. 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"], "status": ["active", "draft"] }
}
```
@@ -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.` |
+843
View File
@@ -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 }
}
```
+147
View File
@@ -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." }
```
+148
View File
@@ -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.` |
+563
View File
@@ -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.
+406
View File
@@ -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.` |
+90
View File
@@ -0,0 +1,90 @@
/***********************************************************************************************************************************************************************
* 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 jwt = require("jsonwebtoken");
const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl");
const s3 = require("../../services/s3.service");
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
function resolveIp(req) {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) return forwarded.split(",")[0].trim();
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
}
// ─── 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, "Asset not found.", 404);
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
}
if (asset.storage_provider !== "s3") {
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
}
const ip = resolveIp(req);
const token = jwt.sign(
{
asset_id,
user_id: req.user.user_id,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip,
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
// ── Presign thumbnail URL so the browser can load it directly ─────────────
let thumbnail_url = null;
if (asset.thumbnail_storage_key) {
try {
thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC);
} catch {
// Non-fatal — thumbnail is cosmetic
}
}
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);
}
};
@@ -0,0 +1,79 @@
/***********************************************************************************************************************************************************************
* 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
* 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 R = require('../../utils/response.util');
// ─── 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,
});
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 } });
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.');
}
}
// ─── 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, markSeen, markAllSeen };
+91
View File
@@ -0,0 +1,91 @@
'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 (per course) ─────────────────────────────────────────────────────
exports.getCourseProduct = async (req, res) => {
try {
const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId }, paranoid: false });
return R.success(res, 'Product retrieved.', product ?? null);
} catch (err) {
console.error('[ADMIN][PRODUCTS][GET]', err);
return R.error(res, 'Could not retrieve product.', 500);
}
};
exports.upsertCourseProduct = async (req, res) => {
try {
const { courseId } = req.params;
const { name, description, price, currency, access_days, is_active } = req.body;
if (!name || price == null) return R.error(res, 'name and price are required.', 400);
const existing = await mdl_Product.findOne({ where: { course_id: courseId }, 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(req.user?.user_id, 'upsert_course_product', { entityType: 'product', details: { course_id: courseId, name } });
return R.success(res, 'Product updated.', existing);
}
const product = await mdl_Product.create({ course_id: courseId, name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', entityId: product.product_id, details: { course_id: courseId, name } });
return R.success(res, 'Product created.', product, 201);
} catch (err) {
console.error('[ADMIN][PRODUCTS][UPSERT]', err);
return R.error(res, 'Could not save product.', 500);
}
};
exports.removeCourseProduct = async (req, res) => {
try {
const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId } });
if (!product) return R.error(res, 'Product not found.', 404);
await product.destroy();
logActivity(req.user?.user_id, 'remove_course_product', { entityType: 'product', details: { course_id: req.params.courseId } });
return R.success(res, 'Product removed.');
} catch (err) {
console.error('[ADMIN][PRODUCTS][REMOVE]', err);
return R.error(res, 'Could not remove product.', 500);
}
};
// ─── 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);
}
};
+123
View File
@@ -0,0 +1,123 @@
/***********************************************************************************************************************************************************************
* 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 { uploadFile, deleteFile } = require('../../services/s3.service');
// ─── 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.', 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.', 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);
// Remove old avatar from S3 before replacing
const oldKey = user.personal_info?.avatar?.uuid;
if (oldKey) await deleteFile(oldKey).catch(() => {});
const { url, uuid } = await uploadFile({
buffer: req.file.buffer,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
ownerType: 'avatar',
});
const merged = {
...(user.personal_info || {}),
avatar: {
url,
uuid,
name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
},
};
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.', updated);
} catch (err) {
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);
const key = user.personal_info?.avatar?.uuid;
if (!key) return R.error(res, 'No avatar to remove.', 404);
await deleteFile(key).catch(() => {});
const merged = { ...(user.personal_info || {}), avatar: null };
await user.update({ personal_info: merged });
return R.success(res, 'Avatar removed.');
} catch (err) {
console.error('[ADMIN] deleteAvatar error:', err);
return R.error(res, 'Could not remove avatar.', 500);
}
};
+51 -13
View File
@@ -20,6 +20,7 @@ const { filterableFields } = require('../../models/task/task.attributes');
const { getFieldValues } = require('../../utils/fieldValues.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 TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
@@ -118,6 +119,7 @@ exports.createTaskList = async (req, res) => {
updatedBy: req.user.user_id,
});
logActivity(req.user.user_id, 'create_task_list', { entityType: 'task_list', entityId: taskList.task_list_id, details: { name: taskList.name } });
return R.success(res, 'Task list created successfully.', taskList, 201);
} catch (err) {
console.error('[ADMIN][CREATE TASK LIST]', err);
@@ -138,6 +140,7 @@ exports.updateTaskList = async (req, res) => {
await taskList.update({ name, description, updatedBy: req.user.user_id });
logActivity(req.user.user_id, 'update_task_list', { entityType: 'task_list', entityId: taskList.task_list_id });
return R.success(res, 'Task list updated successfully.', taskList);
} catch (err) {
console.error('[ADMIN][UPDATE TASK LIST]', err);
@@ -152,12 +155,13 @@ exports.archiveTaskList = async (req, res) => {
try {
const { taskListId } = req.params;
const record = await archiveOne(TaskList, { task_list_id: taskListId },
const record = await archiveOne(TaskList, { task_list_id: taskListId },
req.user.user_id, t
);
if (!record) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
await t.commit();
logActivity(req.user.user_id, 'archive_task_list', { entityType: 'task_list', entityId: Number(taskListId) });
return R.success(res, 'Task list archived successfully.');
} catch (err) {
await t.rollback();
@@ -177,6 +181,7 @@ exports.restoreTaskList = async (req, res) => {
if (!record) { await t.rollback(); return R.error(res, 'Task list not found or not archived.', 404); }
await t.commit();
logActivity(req.user.user_id, 'restore_task_list', { entityType: 'task_list', entityId: Number(taskListId) });
return R.success(res, 'Task list restored successfully.', record);
} catch (err) {
await t.rollback();
@@ -203,6 +208,7 @@ exports.bulkArchiveTaskLists = async (req, res) => {
const count = await archiveMany(TaskList, 'task_list_id', activeIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_task_lists', { entityType: 'task_list', details: { ids: activeIds, count } });
return R.success(res, `${count} task list(s) archived successfully.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
@@ -232,6 +238,7 @@ exports.bulkRestoreTaskLists = async (req, res) => {
const count = await restoreMany(TaskList, 'task_list_id', deletedIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_task_lists', { entityType: 'task_list', details: { ids: deletedIds, count } });
return R.success(res, `${count} task list(s) restored successfully.`, {
restored_ids: deletedIds,
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
@@ -330,6 +337,7 @@ exports.assignGroups = async (req, res) => {
}
await t.commit();
logActivity(req.user.user_id, 'assign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: newIds } });
return R.success(res, `${newIds.length} group(s) assigned.`, {
assigned_ids: newIds,
already_assigned_ids: existingIds,
@@ -384,6 +392,7 @@ exports.unassignGroups = async (req, res) => {
});
await t.commit();
logActivity(req.user.user_id, 'unassign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: existingIds } });
return R.success(res, `${existingIds.length} group(s) unassigned.`, {
unassigned_ids: existingIds,
skipped_ids: skippedIds,
@@ -437,7 +446,6 @@ exports.getTask = async (req, res) => {
{
model: TaskRequirement,
as: 'requirements',
paranoid: false,
attributes: { exclude: adminExclude },
order: [['order', 'ASC']],
},
@@ -515,6 +523,7 @@ exports.createTask = async (req, res) => {
}],
});
logActivity(req.user.user_id, 'create_task', { entityType: 'task', entityId: task.task_id, details: { name: task.name } });
return R.success(res, 'Task created successfully.', full, 201);
} catch (err) {
await t.rollback();
@@ -546,6 +555,23 @@ exports.updateTask = async (req, res) => {
{ transaction: t }
);
// ─────────────────────────────────────────────────────────────────────────────
// PATCH: exports.updateTask in task.controller.js (admin)
//
// BUG: TaskRequirement.destroy({ where: { task_id }, force: false }) is a
// SOFT delete (paranoid: true) — the row stays in the table with deletedAt
// set, still occupying its requirement_id primary key slot. The subsequent
// bulkCreate spread `...r`, which still carried the OLD requirement_id from
// the requirement object the frontend sent back (since RequirementBuilder.jsx
// initializes from the previously-fetched requirements, including their IDs).
// Inserting a new row with that same requirement_id collides with the
// soft-deleted row still sitting on that PK → SequelizeUniqueConstraintError.
//
// FIX: strip requirement_id (and any timestamp fields) from each incoming
// requirement before building reqRows, so bulkCreate always lets the model's
// defaultValue: DataTypes.UUIDV4 generate a fresh ID for the replacement set.
// ─────────────────────────────────────────────────────────────────────────────
if (Array.isArray(requirements)) {
// soft-delete existing requirements then insert fresh ones
await TaskRequirement.destroy({
@@ -555,17 +581,24 @@ exports.updateTask = async (req, res) => {
});
if (requirements.length) {
const reqRows = requirements.map((r, i) => ({
...r,
task_id: task.task_id,
order: r.order ?? i,
reference_id: r.reference_id || null, // '' → null (UUID column)
reference_label: r.reference_label || null, // '' → null
link_url: r.link_url || null,
link_label: r.link_label || null,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}));
const reqRows = requirements.map((r, i) => {
// Strip requirement_id and timestamps — these are server-owned.
// Reusing requirement_id here would collide with the soft-deleted
// row still occupying that primary key.
const { requirement_id, createdAt, updatedAt, deletedAt, ...rest } = r;
return {
...rest,
task_id: task.task_id,
order: rest.order ?? i,
reference_id: rest.reference_id || null, // '' → null (UUID column)
reference_label: rest.reference_label || null, // '' → null
link_url: rest.link_url || null,
link_label: rest.link_label || null,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
};
});
await TaskRequirement.bulkCreate(reqRows, { transaction: t });
}
}
@@ -582,6 +615,7 @@ exports.updateTask = async (req, res) => {
}],
});
logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) });
return R.success(res, 'Task updated successfully.', full);
} catch (err) {
await t.rollback();
@@ -654,6 +688,7 @@ exports.archiveTask = async (req, res) => {
if (!record) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
await t.commit();
logActivity(req.user.user_id, 'archive_task', { entityType: 'task', entityId: Number(taskId) });
return R.success(res, 'Task archived successfully.');
} catch (err) {
await t.rollback();
@@ -678,6 +713,7 @@ exports.restoreTask = async (req, res) => {
if (!record) { await t.rollback(); return R.error(res, 'Task not found or not archived.', 404); }
await t.commit();
logActivity(req.user.user_id, 'restore_task', { entityType: 'task', entityId: Number(taskId) });
return R.success(res, 'Task restored successfully.', record);
} catch (err) {
await t.rollback();
@@ -705,6 +741,7 @@ exports.bulkArchiveTasks = async (req, res) => {
const count = await archiveMany(Task, 'task_id', activeIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_tasks', { entityType: 'task', details: { ids: activeIds, count } });
return R.success(res, `${count} task(s) archived successfully.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
@@ -738,6 +775,7 @@ exports.bulkRestoreTasks = async (req, res) => {
const count = await restoreMany(Task, 'task_id', deletedIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_tasks', { entityType: 'task', details: { ids: deletedIds, count } });
return R.success(res, `${count} task(s) restored successfully.`, {
restored_ids: deletedIds,
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
@@ -0,0 +1,281 @@
/***********************************************************************************************************************************************************************
* 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 { 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);
}
};
// ─── 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);
}
};
+400
View File
@@ -0,0 +1,400 @@
/***********************************************************************************************************************************************************************
* 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 } = require('sequelize');
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 { Course } = require('../../models/courses/courses.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 {
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 PENDING_PAYMENT_EXPIRY_MINUTES = 60;
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',
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);
}
};
exports.createPlan = async (req, res) => {
try {
const { tier, label, duration_days, price, currency } = req.body;
if (!tier || !label || !duration_days || !price)
return R.error(res, 'tier, label, duration_days, and price are required.', 400);
const plan = await mdl_TierPlans.create({ tier, label, duration_days, price, currency });
const plain = plan.get({ plain: true });
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
// Serialize plan_id as string — CockroachDB BigInts exceed JS Number.MAX_SAFE_INTEGER
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', 'duration_days', 'price', 'currency', 'is_active'];
const updates = {};
allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; });
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.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 });
await plan.destroy();
logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
return R.success(res, 'Plan archived successfully.');
} 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 }, { where: { plan_id: activeIds } });
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} plan(s) archived successfully.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} 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.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 tiers retrieved.', tiers);
} catch (err) {
console.error('[ADMIN][GET USER TIERS]', err);
return R.error(res, 'Could not retrieve user tiers.', 500);
}
};
exports.grantTier = async (req, res) => {
try {
const { user_id, tier, plan_id, notes } = req.body;
if (!user_id || !tier || !plan_id)
return R.error(res, 'user_id, tier, 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);
if (plan.tier !== tier) return R.error(res, 'Plan tier mismatch.', 400);
await mdl_UserTiers.update(
{ status: 'expired' },
{ where: { user_id, status: 'active' } }
);
const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({
user_id, tier, status: 'active',
starts_at: startsAt,
expires_at: expiresAt,
granted_by: req.user.user_id,
notes,
});
logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } });
return R.success(res, 'Tier granted.', newTier, 201);
} catch (err) {
console.error('[ADMIN][GRANT TIER]', err);
return R.error(res, 'Could not grant tier.', 500);
}
};
exports.revokeTier = async (req, res) => {
try {
const tierRecord = await mdl_UserTiers.findByPk(req.params.tid);
if (!tierRecord) return R.error(res, 'Tier record not found.', 404);
if (tierRecord.status !== 'active') return R.error(res, 'Tier is not active.', 400);
await tierRecord.update({
status: 'revoked',
revoked_by: req.user.user_id,
revoked_at: new Date(),
});
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, 'Tier revoked. User downgraded to free.');
} catch (err) {
console.error('[ADMIN][REVOKE TIER]', err);
return R.error(res, 'Could not revoke tier.', 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) {
// course_id has a UNIQUE constraint — clear any orphaned/other-plan assignments
// for these courses before inserting, so the insert isn't silently skipped
await mdl_PlanCourses.destroy({ where: { course_id: course_ids } });
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);
}
};
@@ -0,0 +1,128 @@
/***********************************************************************************************************************************************************************
* 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 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) where.created_at[Op.lte] = new Date(query.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 = 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 ──────────────────────────────────────────────────────────────────
function formatRow(row) {
const r = row.toJSON();
const info = r.user?.personal_info;
return {
activity_id: r.activity_id,
user_id: r.user_id,
email: r.user?.email ?? null,
full_name: info?.name?.full_name ?? null,
avatar_url: info?.avatar?.url ?? 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,
};
}
@@ -23,6 +23,7 @@ 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');
// ─── Helper — generate a unique group code ────────────────────────────────────
/**
@@ -110,6 +111,7 @@ exports.createGroup = async (req, res) => {
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);
@@ -141,6 +143,7 @@ exports.updateGroup = async (req, res) => {
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);
@@ -158,6 +161,7 @@ exports.deactivateGroup = async (req, res) => {
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);
@@ -175,6 +179,7 @@ exports.restoreGroup = async (req, res) => {
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);
@@ -204,6 +209,7 @@ exports.bulkDeactivateGroups = async (req, res) => {
);
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)),
@@ -236,6 +242,7 @@ exports.bulkRestoreGroups = async (req, res) => {
{ 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)),
@@ -346,6 +353,7 @@ exports.addUserToGroup = async (req, res) => {
{ ignoreDuplicates: true }
);
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);
@@ -376,6 +384,7 @@ exports.removeUserFromGroup = async (req, res) => {
);
await mdl_UserGroupMembers.destroy({ where: { user_id: user_ids, group_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);
+34 -1
View File
@@ -13,9 +13,11 @@ 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_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const sendEmail = require('../../services/email.service');
const { sendEmail } = require('../../services/email.service');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { paginate } = require('../../utils/paginate.util');
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
@@ -119,6 +121,8 @@ exports.addStaffUser = async (req, res) => {
},
});
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,
@@ -150,6 +154,9 @@ exports.updateUser = async (req, res) => {
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) {
@@ -178,6 +185,8 @@ exports.deactivateUser = async (req, res) => {
{ where: { user_id: 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);
@@ -238,6 +247,8 @@ exports.restoreUser = async (req, res) => {
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);
@@ -304,6 +315,8 @@ exports.terminateSession = async (req, res) => {
logout_info: { date: new Date().toISOString(), forced_by: req.user.user_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);
@@ -350,4 +363,24 @@ exports.getArchivedUsers = async (req, res) => {
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);
}
};
+149 -30
View File
@@ -30,19 +30,19 @@ const mdl_Users = require('../models/users/users.mdl');
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl')
const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util');
const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.util');
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
const sendEmail = require('../services/email.service');
const { onUserRegistered } = require('../services/achievements.service');
const AdminNotification = require('../models/notifications/admin_notification.mdl');
const UserNotification = require('../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
const { sendEmail } = require('../services/email.service');
const buildSessionInfo = require('../utils/session_info.util');
const logActivity = require('../utils/logActivity.util');
const R = require('../utils/response.util');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy'];
// ─── Helpers ───────────────────────────────────────────────────────────────────
const buildLoginInfo = (req) => ({
date: new Date().toISOString(),
ip_address: req.ip,
device_info: req.headers['user-agent'] || 'unknown',
});
const safeUser = (user, extraExclude = []) => {
const u = user.toJSON ? user.toJSON() : { ...user };
@@ -100,7 +100,18 @@ exports.register = async (req, res) => {
await sendEmail({ to: email, type: 'OTP', data: { otp } });
await transaction.commit();
// Fire-and-forget: notify admins about the new group registration
if (group) {
AdminNotification.create({
...NOTIFICATION_REGISTRY.user_registration.build({
groupName: group.name,
groupCode: group.group_code,
userEmail: email,
}),
}).catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err));
}
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
email: user.email,
}, 201);
@@ -131,15 +142,38 @@ exports.verifyOTP = async (req, res) => {
const { accessToken, refreshToken } = generateTokens(user);
const session = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: buildLoginInfo(req),
login_info: await buildSessionInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
}, { transaction });
await sendEmail({ to: email, type: "WELCOME", data: { name: email } });
await transaction.commit();
// Fire-and-forget: activity log
logActivity(user.user_id, 'register');
// Fire-and-forget: achievements, welcome email, notification (do not block the response)
onUserRegistered(user.user_id)
.catch(err => console.error('[AUTH] Failed to grant achievements:', err));
sendEmail({ to: email, type: "WELCOME", data: { name: email } })
.catch(err => console.error('[AUTH] Failed to send welcome email:', err));
mdl_UserGroupMembers.findOne({
where: { user_id: user.user_id },
include: [{ model: mdl_UserGroups, attributes: ['name', 'group_code'] }],
}).then(membership => {
const grp = membership?.UserGroup;
return UserNotification.create({
user_id: user.user_id,
...NOTIFICATION_REGISTRY.welcome.build({
groupName: grp?.name ?? null,
groupCode: grp?.group_code ?? null,
accType: user.acc_type,
}),
});
}).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
@@ -202,11 +236,13 @@ exports.login = async (req, res) => {
const { accessToken, refreshToken } = generateTokens(user);
const session = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: buildLoginInfo(req),
login_info: await buildSessionInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
});
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
@@ -225,28 +261,107 @@ exports.login = async (req, res) => {
}
};
// ─── Google OAuth Callback ─────────────────────────────────────────────────────
// ─── Google OIDC — Redirect ────────────────────────────────────────────────────
// Generates state, nonce, and PKCE verifier, stores them in a signed httpOnly
// cookie, then redirects the browser to Google's authorization endpoint.
exports.googleRedirect = (req, res) => {
const state = generateState();
const nonce = generateNonce();
const { codeVerifier, codeChallenge } = generatePKCE();
// SameSite=Lax is required: the cookie must survive the cross-site redirect
// back from Google (top-level GET navigations are allowed under Lax).
res.cookie('_oauth', JSON.stringify({ state, nonce, codeVerifier }), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 10 * 60 * 1000, // 10 minutes — enough time to complete the flow
signed: true,
});
return res.redirect(buildAuthUrl(state, nonce, codeChallenge));
};
// ─── Google OIDC — Callback ────────────────────────────────────────────────────
// Verifies state (CSRF), exchanges the authorization code, verifies the ID token
// (signature + nonce), finds or creates the user, sets the refresh cookie, then
// redirects the browser to the frontend callback page.
exports.googleCallback = async (req, res) => {
const FRONTEND_URL = process.env.FRONTEND_URL;
const CALLBACK_PAGE = `${FRONTEND_URL}/auth/callback/google`;
try {
const user = req.user; // set by passport
const { code, state, error } = req.query;
if (error) {
return res.redirect(`${CALLBACK_PAGE}?error=${encodeURIComponent(error)}`);
}
// Read and immediately clear the oauth state cookie.
const rawCookie = req.signedCookies['_oauth'];
res.clearCookie('_oauth');
if (!rawCookie) return res.redirect(`${CALLBACK_PAGE}?error=session_expired`);
const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie);
if (!state || state !== expectedState) {
return res.redirect(`${CALLBACK_PAGE}?error=state_mismatch`);
}
// Exchange authorization code → { id_token, access_token, ... }
const tokens = await exchangeCode(code, codeVerifier);
// Verify ID token signature, audience, expiry, and nonce.
const payload = await verifyIdToken(tokens.id_token, nonce);
// Find or auto-create the user.
let user = await mdl_Users.findOne({ where: { email: payload.email } });
if (!user) {
user = await mdl_Users.create({
email: payload.email,
reg_type: 'google',
acc_type: 'user',
is_active: true,
is_verified: true,
personal_info: {
name: {
given_name: payload.given_name ?? '',
last_name: payload.family_name ?? '',
full_name: payload.name ?? '',
},
avatar: { url: payload.picture ?? null },
},
});
}
if (!user.is_active) {
return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`);
}
const { accessToken, refreshToken } = generateTokens(user);
await mdl_UserSessions.create({
user_id: user.user_id,
login_info: buildLoginInfo(req),
const googleSession = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: await buildSessionInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
is_active: true,
});
// In a real SPA: redirect with tokens in query or set httpOnly cookie
return R.success(res, 'Google login successful.', {
accessToken,
refreshToken,
user: safeUser(user),
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(googleSession.session_id), details: { reg_type: 'google' } });
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
// Redirect to the frontend; App.jsx's restoreSession() will pick up the
// refresh cookie and complete the login automatically.
return res.redirect(CALLBACK_PAGE);
} catch (err) {
console.error('[AUTH] googleCallback error:', err);
return R.error(res, 'Google authentication failed.', 500);
console.error('[AUTH] googleCallback OIDC error:', err);
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google?error=auth_failed`);
}
};
@@ -270,7 +385,7 @@ exports.refreshToken = async (req, res) => {
// Check if refresh token is expired
if (!shouldRotateRefreshToken(decoded)) {
const { accessToken } = generateTokens(user);
return R.success(res, 'Token refreshed.', { accessToken, user: safeUser(user) });
return R.success(res, 'Token refreshed.', { accessToken, session_id: session.session_id, user: safeUser(user) });
}
// ─── Rotate refresh token ───────────────────────────────────────────────────
@@ -284,7 +399,7 @@ exports.refreshToken = async (req, res) => {
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) });
return R.success(res, 'Token refreshed.', { ...tokens, session_id: session.session_id, user: safeUser(user) });
} catch (err) {
console.error('[AUTH] refresh token error:', err);
return R.error(res, 'Invalid or expired refresh token.', 401);
@@ -297,11 +412,13 @@ exports.logout = async (req, res) => {
const { session_id } = req.body;
if (session_id) {
await mdl_UserSessions.update(
{ is_active: false, logout_info: buildLoginInfo(req) },
{ is_active: false, logout_info: await buildSessionInfo(req) },
{ where: { session_id, user_id: req.user.user_id } }
);
}
logActivity(req.user.user_id, 'logout', { entityType: 'session', entityId: session_id ? Number(session_id) : null });
res.clearCookie('refreshToken')
res.clearCookie('_csrf')
@@ -330,6 +447,8 @@ exports.changePassword = async (req, res) => {
// Invalidate all sessions to force re-login
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
logActivity(user.user_id, 'password_change');
return R.success(res, 'Password changed. All sessions have been invalidated. Please log in again.');
} catch (err) {
console.error('[AUTH] changePassword error:', err);
@@ -0,0 +1,100 @@
// controllers/client/advertisements.controller.js
const Advertisement = require("../../models/advertisements/advertisements.mdl");
const mdl_Assets = require("../../models/assets/assets.mdl");
const R = require('../../utils/response.util');
const { Op } = require('sequelize');
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
// ─── Status derivation ─────────────────────────────────────────────────────
// Mirrors admin controller's deriveStatus — single source of truth for what
// "live right now" means. Kept duplicated rather than shared to avoid a
// cross-boundary import between admin and client controllers.
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";
}
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
//
// Resolves the single highest-priority live advertisement for a given placement
// type. "Live" means is_active = true AND within start_date/end_date window —
// computed the same way as deriveStatus, but expressed as a SQL WHERE clause
// here since we want the DB to do the filtering/ordering, not JS.
//
// GET /api/client/advertisements/active?type=hero
//
exports.getActiveAdvertisement = async (req, res) => {
try {
const { type } = req.query;
if (!type) return R.error(res, "type is required.", 400);
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400);
const now = new Date();
const advertisement = await Advertisement.findOne({
where: {
type,
is_active: true,
deletedAt: null,
[Op.and]: [
{ [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] },
{ [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] },
],
},
order: [["order", "ASC"], ["createdAt", "DESC"]],
include: [{
model: mdl_Assets,
as: "image",
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
required: false,
}],
attributes: { exclude: ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"] },
});
if (!advertisement) return R.success(res, "No active advertisement for this placement.", { data: null });
const json = advertisement.toJSON();
json.status = deriveStatus(json); // will always be "active" given the WHERE clause, but kept for shape consistency
return R.success(res, "Active advertisement retrieved.", { data: json });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE]", err);
return R.error(res, "Could not retrieve advertisement.", 500);
}
};
// ─── TRACK CLICK ──────────────────────────────────────────────────────────────
//
// POST /api/client/advertisements/:advertisementId/click
// Fire-and-forget increment. Never blocks or surfaces errors to the user —
// a failed click tracking call should never disrupt navigation to the CTA link.
//
exports.trackClick = 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, deletedAt: null } });
if (!advertisement) return R.success(res, "Advertisement not found, skipped.", { data: null });
await advertisement.increment("click_count");
return R.success(res, "Click tracked.", { data: { click_count: advertisement.click_count + 1 } });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][TRACK CLICK]", err);
// Still respond 200-ish/success shape — click tracking failures shouldn't surface to the user.
return R.success(res, "Click tracking failed silently.", { data: null });
}
};
@@ -0,0 +1,214 @@
/***********************************************************************************************************************************************************************
* File Name: certificate.controller.js (client)
* Type of Program: Controller
* Description: Issues a PDF certificate for a completed course.
* A certificate is available only when the user has passed the course assessment.
* Certificate records are persisted (findOrCreate) so the same cert_no/ref_no is
* returned on every subsequent download.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 18, 2026
***********************************************************************************************************************************************************************/
'use strict';
const R = require('../../utils/response.util');
const mdl_Users = require('../../models/users/users.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl');
const { generateCertificate } = require('../../services/certificate.service');
const { formatDuration } = require('../../utils/duration.util');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const {
Course,
CourseAssessment,
QuizAttempt,
Certificate,
CourseInstructor,
} = require('../../models/courses/courses.associations');
const notDeleted = { deletedAt: null };
// ─── Helpers ───────────────────────────────────────────────────────────────────
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
// userCertSeq = how many certs this user will have after this insert
async function buildCertNo(userId) {
const count = await Certificate.count({ where: { user_id: userId } });
const seq = String(count + 1).padStart(5, '0');
const uid = String(userId).padStart(6, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `${YYYYMM}-${uid}-${seq}`;
}
// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs)
async function buildRefNo() {
const count = await Certificate.count();
const seq = String(count + 1).padStart(5, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `PP-${YYYYMM}-${seq}`;
}
function formatInstructors(rows) {
const names = rows.map(r => r.display_name);
if (names.length === 0) return '';
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names[0]}, ${names[1]} and et. al`;
}
// ─── GET /api/client/certificates/:courseUuid ──────────────────────────────────
exports.getCertificate = async (req, res) => {
try {
const { courseUuid } = req.params;
const user_id = req.user.user_id;
// ── 1. Resolve course ──────────────────────────────────────────────────────
const course = await Course.findOne({
where: { uuid: courseUuid, ...notDeleted },
attributes: ['course_id', 'title', 'course_code', 'duration_seconds'],
include: [
{
model: CourseAssessment,
as: 'assessment',
attributes: ['assessment_id'],
required: false,
},
{
model: CourseInstructor,
as: 'instructors',
attributes: ['display_name', 'order_index'],
required: false,
order: [['order_index', 'ASC']],
},
],
});
if (!course) return R.error(res, 'Course not found.', 404);
if (!course.assessment) {
return R.error(res, 'This course does not have an assessment — no certificate available.', 404);
}
// ── 2. Verify the user passed ──────────────────────────────────────────────
const passedAttempt = await QuizAttempt.findOne({
where: {
user_id,
assessment_id: course.assessment.assessment_id,
passed: true,
},
order: [['createdAt', 'DESC']],
attributes: ['score', 'createdAt'],
});
if (!passedAttempt) {
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
}
// ── 3. Get user's name ─────────────────────────────────────────────────────
const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] });
const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant';
// ── 4. Resolve or create the certificate record ────────────────────────────
const [cert, created] = await Certificate.findOrCreate({
where: { user_id, course_id: course.course_id },
defaults: {
cert_no: await buildCertNo(user_id),
ref_no: await buildRefNo(),
instructors: formatInstructors(course.instructors ?? []),
score: passedAttempt.score ?? null,
length_str: formatDuration(course.duration_seconds),
issued_at: passedAttempt.createdAt,
},
});
// Always use live instructors from course_instructors table for the PDF.
// Keep the snapshot in sync so it reflects the current state.
const liveInstructors = formatInstructors(course.instructors ?? []);
if (liveInstructors !== (cert.instructors ?? '')) {
await cert.update({ instructors: liveInstructors });
}
// ── 5. On first issue: fire notification + achievements ────────────────────
if (created) {
// Certificate issued notification
UserNotification.create({
user_id,
...NOTIFICATION_REGISTRY.certificate_issued.build({
courseTitle: course.title,
courseUuid,
}),
}).catch(err => console.error('[CERTIFICATE] Failed to emit notification:', err));
// Per-course completion achievement
mdl_Achievements.findOrCreate({
where: { user_id, key: `course_completed_${courseUuid}` },
defaults: {
type: 'milestone',
label: 'Certificate of Completion',
description: course.title,
granted_at: passedAttempt.createdAt,
metadata: { courseTitle: course.title, courseUuid },
},
}).catch(err => console.error('[CERTIFICATE] Failed to grant course achievement:', err));
// First-course achievement (only if this is their very first certificate)
const totalCerts = await Certificate.count({ where: { user_id } });
if (totalCerts === 1) {
mdl_Achievements.findOrCreate({
where: { user_id, key: 'first_course_completed' },
defaults: {
type: 'milestone',
label: 'First Course Completed',
description: 'Completed your very first course on Philproperties.',
granted_at: passedAttempt.createdAt,
metadata: { courseTitle: course.title, courseUuid },
},
}).catch(err => console.error('[CERTIFICATE] Failed to grant first-course achievement:', err));
}
}
// ── 6. Format issued date as MM/DD/YY HH:MM AM/PM ────────────────────────
const issuedDate = new Date(cert.issued_at);
const dateStr = new Intl.DateTimeFormat('en-US', {
month: '2-digit',
day: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: true,
}).format(issuedDate);
// ── 7. Generate PDF ────────────────────────────────────────────────────────
const pdf = await generateCertificate({
name: fullName,
course: course.title,
date: dateStr,
cert_no: cert.cert_no,
ref_no: cert.ref_no,
instructors: liveInstructors,
length: cert.length_str ?? '',
});
// ── 8. Stream response ─────────────────────────────────────────────────────
const nameParts = fullName.trim().split(/\s+/);
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0];
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
const safeTitle = course.title.replace(/[/\\:*?"<>|]/g, '').trim();
const filename = `${lastName},${firstName}_${safeTitle}_${cert.cert_no}.pdf`;
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': pdf.length,
});
return res.send(pdf);
} catch (err) {
console.error('[CLIENT][CERTIFICATE]', err);
return R.error(res, 'Could not generate certificate.', 500);
}
};
@@ -0,0 +1,157 @@
'use strict';
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const mdl_Product = require('../../models/courses/products.mdl');
const paypal = require('../../services/paypal.service');
const R = require('../../utils/response.util');
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
exports.createCourseOrder = async (req, res) => {
try {
const { product_id } = req.body;
if (!product_id) return R.error(res, 'product_id is required.', 400);
const product = await mdl_Product.findOne({ where: { id: product_id, is_active: true } });
if (!product) return R.error(res, 'Product not found or inactive.', 404);
// Block if user already has an active completed purchase for this product
const existing = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, product_id, status: 'completed' },
});
if (existing) {
const stillActive = !existing.expires_at || new Date(existing.expires_at) > new Date();
if (stillActive) return R.error(res, 'You already have active access to this course.', 409);
}
const ppOrder = await paypal.createOrder({
amount: Number(product.price).toFixed(2),
currency: product.currency,
referenceId: `user_${req.user.user_id}_product_${product_id}`,
returnUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout`,
cancelUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout?cancelled=true`,
});
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
const expiresAt = product.access_days
? new Date(Date.now() + product.access_days * 86400000)
: null;
const purchase = await mdl_CoursePurchase.create({
user_id: req.user.user_id,
product_id,
amount: product.price,
currency: product.currency,
status: 'pending',
provider: 'paypal',
expires_at: expiresAt,
provider_payload: { order_id: ppOrder.id, approval_url: approvalUrl },
});
return R.success(res, 'Order created.', {
purchase_id: purchase.id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: product.price,
currency: product.currency,
}, 201);
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CREATE ORDER]', err);
return R.error(res, 'Could not create order.', 500);
}
};
// ─── CAPTURE ORDER ────────────────────────────────────────────────────────────
exports.captureCourseOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const purchase = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
include: [{ model: mdl_Product, as: 'product' }],
order: [['createdAt', 'DESC']],
});
if (!purchase || purchase.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending purchase not found.', 404);
let captureData;
try {
captureData = await paypal.captureOrder(order_id);
} catch (ppErr) {
await purchase.update({
status: 'failed',
provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} },
});
return R.error(res, 'PayPal capture failed.', 402);
}
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
await purchase.update({
status: 'completed',
paid_at: new Date(),
provider_payload: {
...purchase.provider_payload,
capture_id: capture?.id,
payer_id: captureData.payer?.payer_id,
capture: captureData,
},
});
return R.success(res, 'Payment successful. Course access granted.', {
purchase_id: purchase.id,
expires_at: purchase.expires_at,
course_id: purchase.product.course_id,
});
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err);
return R.error(res, 'Could not capture order.', 500);
}
};
// ─── CANCEL ORDER ─────────────────────────────────────────────────────────────
exports.cancelCourseOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const purchase = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
order: [['createdAt', 'DESC']],
});
if (!purchase || purchase.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending purchase not found.', 404);
await purchase.update({
status: 'cancelled',
provider_payload: { ...purchase.provider_payload, cancelled_at: new Date().toISOString() },
});
return R.success(res, 'Purchase cancelled.');
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CANCEL]', err);
return R.error(res, 'Could not cancel purchase.', 500);
}
};
// ─── MY PURCHASES ─────────────────────────────────────────────────────────────
exports.getMyPurchases = async (req, res) => {
try {
const purchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id },
include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'course_id', 'access_days'] }],
attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Purchases retrieved.', purchases);
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][GET MINE]', err);
return R.error(res, 'Could not retrieve purchases.', 500);
}
};
@@ -0,0 +1,312 @@
/***********************************************************************************************************************************************************************
* File Name: course_reading_progress.controller.js (client)
* Type of Program: Controller
* Description: Tracks user reading progress through a course hierarchy (course → unit → lesson).
*
* GET /client/courses/in-progress
* → courses where the current user has status = 'in_progress', with lesson counts
*
* GET /client/courses/:courseId/progress
* → returns all progress rows for this user + course (flat, frontend builds the map)
*
* POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
* → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction
*
* Body (POST):
* { status: 'in_progress' | 'completed' }
* Defaults to 'in_progress' if omitted (on first visit).
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
'use strict';
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
const {
Course, Unit, Lesson,
UnitQuiz, CourseAssessment, QuizAttempt,
} = require('../../models/courses/courses.associations');
const notDeleted = { deletedAt: null };
// =============================================================================
// ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
// =============================================================================
// GET /client/courses/in-progress
// Returns courses the user has started but not yet completed (no certificate).
// Includes both:
// • reading in_progress → still working through lessons
// • reading completed → finished lessons but quiz / assessment still pending
// Excludes any course where the user already holds a certificate.
exports.getMyInProgressCourses = async (req, res) => {
try {
const userId = req.user.user_id;
// All course-level progress rows for this user (any reading status)
const courseRows = await CourseReadingProgress.findAll({
where: { user_id: userId, type: 'course' },
attributes: ['course_id', 'status', 'last_accessed_at'],
include: [{
model: Course,
as: 'course',
attributes: ['course_id', 'title'],
where: notDeleted,
required: true,
}],
order: [['last_accessed_at', 'DESC']],
});
if (!courseRows.length) return R.success(res, 'No courses in progress.', []);
// Courses the user has already earned a certificate for — exclude these
const certificates = await Certificate.findAll({
where: { user_id: userId },
attributes: ['course_id'],
});
const certSet = new Set(certificates.map((c) => String(c.course_id)));
const pending = courseRows.filter((r) => !certSet.has(String(r.course_id)));
if (!pending.length) return R.success(res, 'No courses in progress.', []);
const result = await Promise.all(pending.map(async (row) => {
const courseId = row.course_id;
const readingDone = row.status === 'completed';
const [lessons_total, lessons_completed] = await Promise.all([
Lesson.count({
include: [{
model: Unit,
as: 'unit',
where: { course_id: courseId, ...notDeleted },
required: true,
}],
where: notDeleted,
}),
CourseReadingProgress.count({
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
}),
]);
// Only compute pending quiz/assessment detail when all lessons are read
let pending_quizzes = [];
let pending_assessment = null;
if (readingDone) {
// All unit quizzes in this course
const unitQuizzes = await UnitQuiz.findAll({
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
include: [{
model: Unit,
as: 'unit',
attributes: ['unit_id', 'title'],
where: { course_id: courseId, ...notDeleted },
required: true,
}],
where: notDeleted,
});
for (const quiz of unitQuizzes) {
const [hasPassed, attemptCount] = await Promise.all([
QuizAttempt.findOne({ where: { user_id: userId, quiz_id: quiz.quiz_id, passed: true } }),
QuizAttempt.count({ where: { user_id: userId, quiz_id: quiz.quiz_id } }),
]);
if (!hasPassed) {
pending_quizzes.push({
quiz_id: quiz.quiz_id,
title: quiz.title,
unit_title: quiz.unit.title,
is_required: quiz.is_required,
passing_score: quiz.passing_score,
attempt_count: attemptCount,
});
}
}
// Course assessment
const assessment = await CourseAssessment.findOne({
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
where: { course_id: courseId },
});
if (assessment) {
const [hasPassed, attemptCount] = await Promise.all([
QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true } }),
QuizAttempt.count({ where: { user_id: userId, assessment_id: assessment.assessment_id } }),
]);
if (!hasPassed) {
pending_assessment = {
assessment_id: assessment.assessment_id,
title: assessment.title,
is_required: assessment.is_required,
passing_score: assessment.passing_score,
attempt_count: attemptCount,
};
}
}
}
return {
course_id: courseId,
title: row.course.title,
reading_status: row.status,
lessons_total,
lessons_completed,
last_accessed_at: row.last_accessed_at,
pending_quizzes,
pending_assessment,
};
}));
return R.success(res, 'In-progress courses retrieved.', result);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][IN PROGRESS]', err);
return R.error(res, 'Could not retrieve in-progress courses.', 500);
}
};
// =============================================================================
// ── GET PROGRESS SUMMARY ──────────────────────────────────────────────────────
// =============================================================================
// GET /client/courses/:courseId/progress/summary
// Returns a compact progress snapshot: lesson counts + percentage + course status.
// Used by the ReadCourse block to render the inline progress bar without needing
// the full flat row list.
exports.getCourseProgressSummary = async (req, res) => {
try {
const { courseId } = req.params;
const userId = req.user.user_id;
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id'],
});
if (!course) return R.error(res, 'Course not found.', 404);
const [lessons_total, lessons_completed, courseRow] = await Promise.all([
Lesson.count({
include: [{
model: Unit,
as: 'unit',
where: { course_id: courseId, ...notDeleted },
required: true,
}],
where: notDeleted,
}),
CourseReadingProgress.count({
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
}),
CourseReadingProgress.findOne({
where: { user_id: userId, course_id: courseId, type: 'course' },
attributes: ['status'],
}),
]);
const percent = lessons_total > 0 ? Math.round((lessons_completed / lessons_total) * 100) : 0;
return R.success(res, 'Progress summary retrieved.', {
lessons_total,
lessons_completed,
percent,
status: courseRow?.status ?? null,
});
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][SUMMARY]', err);
return R.error(res, 'Could not retrieve progress summary.', 500);
}
};
// =============================================================================
// ── GET PROGRESS SNAPSHOT ─────────────────────────────────────────────────────
// =============================================================================
// GET /client/courses/:courseId/progress
// Returns all progress rows for this user+course.
// Frontend uses this to decorate the sidebar (completed checkmarks, locked states, etc.)
exports.getCourseProgress = async (req, res) => {
try {
const { courseId } = req.params;
const userId = req.user.user_id;
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id'],
});
if (!course) return R.error(res, 'Course not found.', 404);
const rows = await CourseReadingProgress.findAll({
where: { user_id: userId, course_id: courseId },
attributes: ['progress_id', 'reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'],
});
return R.success(res, 'Course progress retrieved.', rows);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][GET]', err);
return R.error(res, 'Could not retrieve course progress.', 500);
}
};
// =============================================================================
// ── UPSERT LESSON PROGRESS ────────────────────────────────────────────────────
// =============================================================================
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
// Body: { status: 'in_progress' | 'completed' }
//
// Flow:
// 1. Resolve course / unit / lesson to get their UUIDs
// 2. Delegate to upsertLessonRead — handles lesson + unit + course in one tx
exports.upsertLessonProgress = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const userId = req.user.user_id;
const status = req.body.status === 'completed' ? 'completed' : 'in_progress';
const [course, unit, lesson] = await Promise.all([
Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id', 'uuid'],
}),
Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
attributes: ['unit_id', 'uuid'],
}),
Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
attributes: ['lesson_id', 'uuid'],
}),
]);
if (!course) return R.error(res, 'Course not found.', 404);
if (!unit) return R.error(res, 'Unit not found.', 404);
if (!lesson) return R.error(res, 'Lesson not found.', 404);
const result = await upsertLessonRead(userId, {
courseId: course.course_id,
courseUuid: course.uuid,
unitId: unit.unit_id,
unitUuid: unit.uuid,
lessonUuid: lesson.uuid,
lessonStatus: status,
});
logActivity(userId, 'lesson_read', {
entityType: 'lesson',
entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status },
});
return R.success(res, 'Progress updated.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err);
return R.error(res, 'Could not update progress.', 500);
}
};
+727
View File
@@ -0,0 +1,727 @@
/***********************************************************************************************************************************************************************
* File Name: courses.controller.js (client)
* Type of Program: Controller
* Description: User-facing course endpoints (read-only).
* Access rules:
* - All courses are returned in the list (for upsell visibility)
* - Each course has is_locked: boolean based on the user's active tier
* - free / no active tier → unassigned courses are open; plan courses are locked
* - premium (active tier) → unassigned + courses under their plan are open
* - getCourse still enforces hard 403 on locked access
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 7, 2026
***********************************************************************************************************************************************************************/
"use strict";
const { Op } = require("sequelize");
const R = require("../../utils/response.util");
const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl");
const mdl_PlanCourses = require("../../models/tiers/plan_courses.mdl");
const mdl_TierPlans = require("../../models/tiers/tier_plans.mdl");
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const mdl_Product = require("../../models/courses/products.mdl");
const mdl_Category = require("../../models/courses/categories.mdl");
const {
Course,
Unit, Lesson, LessonPage,
CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt
} = require("../../models/courses/courses.associations");
const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, getAttemptStatus, MAX_ATTEMPTS } = require("../../utils/courses/quiz_security.util");
const { onCourseCompleted } = require('../../services/achievements.service')
const notDeleted = { deletedAt: null };
const COURSE_LIST_ATTRS = [
"course_id", "uuid", "title", "description",
"course_code", "level", "subscription",
"duration_seconds", "order_index",
];
// Strip correct-answer data before sending quiz questions to the client
function sanitizeQuestions(questions = []) {
return questions.map((q) => {
const plain = q.toJSON ? q.toJSON() : { ...q };
plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o);
delete plain.explanation;
return plain;
});
}
// Resolve the caller's active tier (returns null if free/expired)
async function getActiveTier(user_id) {
return mdl_UserTiers.findOne({
where: { user_id, status: "active" },
order: [["createdAt", "DESC"]],
});
}
// ─── COURSES (all visible, is_locked per user tier) ───────────────────────────
exports.getCourses = async (req, res) => {
try {
const { category } = req.query; // optional slug filter
const activeTier = await getActiveTier(req.user.user_id);
const userTier = activeTier?.tier ?? 'free';
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const userRank = tierRank[userTier] ?? 0;
// Fetch all completed purchases for this user (for has_purchased check)
const myPurchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id, status: 'completed' },
include: [{ model: mdl_Product, as: 'product', attributes: ['course_id', 'access_days'] }],
attributes: ['id', 'expires_at', 'product_id'],
});
const purchasedCourseIds = new Set(
myPurchases
.filter((p) => !p.expires_at || new Date(p.expires_at) > new Date())
.map((p) => String(p.product?.course_id))
);
// Build category filter
const categoryInclude = {
model: mdl_Category,
as: 'categories',
through: { attributes: [] },
attributes: ['id', 'name', 'slug'],
required: !!category,
...(category ? { where: { slug: category } } : {}),
};
const courses = await Course.findAll({
where: { ...notDeleted },
attributes: COURSE_LIST_ATTRS,
include: [
{
model: mdl_PlanCourses,
as: 'planCourse',
required: false,
attributes: ['id', 'plan_id'],
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }],
},
{
model: mdl_Product,
as: 'product',
required: false,
attributes: ['id', 'name', 'price', 'currency', 'access_days', 'is_active'],
paranoid: false,
},
categoryInclude,
],
order: [['order_index', 'ASC'], ['title', 'ASC']],
});
const result = courses.map((c) => {
const plain = c.toJSON();
const planCourse = plain.planCourse;
const plan_tier = planCourse?.plan?.tier ?? null;
const has_purchased = purchasedCourseIds.has(String(plain.course_id));
let is_locked = false;
if (plan_tier && plan_tier !== 'free') {
const reqRank = tierRank[plan_tier] ?? 0;
if (userRank < reqRank && !has_purchased) is_locked = true;
}
delete plain.planCourse;
return { ...plain, is_locked, plan_tier, has_purchased };
});
return R.success(res, "Courses retrieved.", result);
} catch (err) {
console.error("[CLIENT][COURSES][GET ALL]", err);
return R.error(res, "Could not retrieve courses.", 500);
}
};
// ─── COURSE DETAIL (hard access check) ───────────────────────────────────────
exports.getCourse = async (req, res) => {
try {
const { courseId } = req.params;
// Access check — tier OR individual purchase
const activeTier = await getActiveTier(req.user.user_id);
const planCourse = await mdl_PlanCourses.findOne({ where: { course_id: courseId } });
let plan = null;
if (planCourse) {
plan = await mdl_TierPlans.findByPk(planCourse.plan_id, { attributes: ['tier'] });
const requiredTier = plan?.tier ?? 'free';
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0;
const reqRank = tierRank[requiredTier] ?? 0;
if (userRank < reqRank) {
// Check individual purchase as fallback
const product = await mdl_Product.findOne({ where: { course_id: courseId } });
const hasPurchase = product && await mdl_CoursePurchase.findOne({
where: {
user_id: req.user.user_id, product_id: product.id, status: 'completed',
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
});
if (!hasPurchase) return R.error(res, "You do not have access to this course.", 403);
}
}
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: COURSE_LIST_ATTRS,
include: [
{
model: Unit, as: "units",
where: notDeleted, required: false,
attributes: [
"unit_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
include: [
{
model: Lesson, as: "lessons",
where: notDeleted, required: false,
attributes: [
"lesson_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
},
{
model: UnitQuiz, as: "quiz",
required: false,
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions",
],
},
],
},
{
model: CourseObjective, as: "objectives",
required: false,
attributes: ["objective_id", "text", "order_index"],
},
{
model: CoursePrerequisite, as: "prerequisites",
required: false,
attributes: ["prereq_id", "ref_type", "ref_id", "order_index"],
},
{
model: CourseAssessment, as: "assessment",
required: false,
attributes: [
"assessment_id", "uuid", "title",
"is_required", "passing_score",
"time_limit_minutes", "max_questions",
],
},
],
order: [
[{ model: Unit, as: "units" }, "order_index", "ASC"],
[{ model: Unit, as: "units" }, { model: Lesson, as: "lessons" }, "order_index", "ASC"],
[{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"],
[{ model: CoursePrerequisite, as: "prerequisites" }, "order_index", "ASC"],
],
});
if (!course) return R.error(res, "Course not found.", 404);
const plain = course.toJSON();
// Attach has_passed to each unit's quiz in one query
const quizIds = plain.units
?.map((u) => u.quiz?.quiz_id)
.filter(Boolean) ?? [];
if (quizIds.length) {
const passedQuizAttempts = await QuizAttempt.findAll({
where: { quiz_id: quizIds, user_id: req.user.user_id, passed: true },
attributes: ["quiz_id"],
});
const passedSet = new Set(passedQuizAttempts.map((a) => String(a.quiz_id)));
plain.units = plain.units.map((u) => ({
...u,
quiz: u.quiz ? { ...u.quiz, has_passed: passedSet.has(String(u.quiz.quiz_id)) } : null,
}));
}
let is_completed = false;
if (plain.assessment) {
const passedAttempt = await QuizAttempt.findOne({
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
});
is_completed = !!passedAttempt;
}
plain.is_completed = is_completed;
const plan_tier = plan?.tier ?? null;
// Attach product info and purchase status for the buy-course flow
const product = await mdl_Product.findOne({
where: { course_id: courseId, is_active: true },
attributes: ['id', 'name', 'price', 'currency', 'access_days'],
});
const hasPurchase = product && await mdl_CoursePurchase.findOne({
where: {
user_id: req.user.user_id, product_id: product.id, status: 'completed',
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
});
return R.success(res, "Course retrieved.", {
...plain,
plan_tier,
product: product ?? null,
has_purchased: !!hasPurchase,
});
} catch (err) {
console.error("[CLIENT][COURSES][GET ONE]", err);
return R.error(res, "Could not retrieve course.", 500);
}
};
// ─── UNIT ─────────────────────────────────────────────────────────────────────
exports.getUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
attributes: [
"unit_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
include: [
{
model: Lesson, as: "lessons",
where: notDeleted, required: false,
attributes: [
"lesson_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
},
{
model: UnitQuiz, as: "quiz",
required: false,
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions",
],
},
],
order: [[{ model: Lesson, as: "lessons" }, "order_index", "ASC"]],
});
if (!unit) return R.error(res, "Unit not found.", 404);
return R.success(res, "Unit retrieved.", unit);
} catch (err) {
console.error("[CLIENT][UNIT][GET ONE]", err);
return R.error(res, "Could not retrieve unit.", 500);
}
};
// ─── LESSON ───────────────────────────────────────────────────────────────────
exports.getLesson = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
attributes: [
"lesson_id", "uuid", "unit_id", "title",
"description", "order_index", "duration_seconds",
],
include: [
{
model: Unit, as: "unit",
where: { course_id: courseId, ...notDeleted },
attributes: [],
},
{
model: LessonPage, as: "page",
required: false,
attributes: ["page_id", "blocks"],
},
{
model: LessonObjective, as: "objectives",
required: false,
attributes: ["objective_id", "text", "order_index"],
},
],
order: [[{ model: LessonObjective, as: "objectives" }, "order_index", "ASC"]],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
return R.success(res, "Lesson retrieved.", lesson);
} catch (err) {
console.error("[CLIENT][LESSON][GET ONE]", err);
return R.error(res, "Could not retrieve lesson.", 500);
}
};
// ─── QUIZ (no answers) ────────────────────────────────────────────────────────
exports.getUnitQuiz = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { unit_id: unitId, ...notDeleted },
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions",
],
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
include: [{
model: QuizOption, as: "options",
attributes: ["option_id", "text", "order_index"],
}],
}],
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
const plain = quiz.toJSON();
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
const attempts = await QuizAttempt.findAll({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id },
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"],
});
const status = getAttemptStatus(attempts);
plain.attempt_count = status.attempt_count;
plain.has_passed = status.has_passed;
plain.best_attempt = status.best_attempt;
plain.attempts_remaining = status.attempts_remaining;
plain.cooldown_until = status.cooldown_until;
plain.window_reset_at = status.window_reset_at;
plain.can_attempt = status.can_attempt;
return R.success(res, "Quiz retrieved.", plain);
} catch (err) {
console.error("[CLIENT][QUIZ][GET]", err);
return R.error(res, "Could not retrieve quiz.", 500);
}
};
// ─── ASSESSMENT (no answers) ──────────────────────────────────────────────────
exports.getCourseAssessment = async (req, res) => {
try {
const { courseId } = req.params;
const assessment = await CourseAssessment.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: [
"assessment_id", "uuid", "title",
"is_required", "passing_score",
"time_limit_minutes", "max_questions",
],
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
include: [{
model: QuizOption, as: "options",
attributes: ["option_id", "text", "order_index"],
}],
}],
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
});
if (!assessment) return R.error(res, "Assessment not found.", 404);
const plain = assessment.toJSON();
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
const attempts = await QuizAttempt.findAll({
where: { assessment_id: assessment.assessment_id, user_id: req.user.user_id },
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"],
});
const status = getAttemptStatus(attempts);
plain.attempt_count = status.attempt_count;
plain.has_passed = status.has_passed;
plain.best_attempt = status.best_attempt;
plain.attempts_remaining = status.attempts_remaining;
plain.cooldown_until = status.cooldown_until;
plain.window_reset_at = status.window_reset_at;
plain.can_attempt = status.can_attempt;
return R.success(res, "Assessment retrieved.", plain);
} catch (err) {
console.error("[CLIENT][ASSESSMENT][GET]", err);
return R.error(res, "Could not retrieve assessment.", 500);
}
};
// ─── QUIZ SUBMIT ──────────────────────────────────────────────────────────────
exports.submitUnitQuiz = async (req, res) => {
try {
const { courseId, unitId, quizId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { quiz_id: quizId, unit_id: unitId, ...notDeleted },
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
include: [{ model: QuizOption, as: "options" }],
}],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
const priorAttempts = await QuizAttempt.findAll({
where: { quiz_id: quiz.quiz_id, user_id },
attributes: ["attempt_id", "score", "passed", "createdAt"],
});
const status = getAttemptStatus(priorAttempts);
if (!status.can_attempt) {
if (status.cooldown_until) {
return R.error(res, "Please wait a bit before retaking this quiz — check the quiz screen for when you can try again.", 429);
}
return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429);
}
const { totalPoints, earnedPoints, score } = gradeSubmission(quiz.questions ?? [], answers);
const passed = score >= (quiz.passing_score ?? 70);
const attempt = await QuizAttempt.create({
user_id,
quiz_id: quiz.quiz_id,
course_id: courseId,
attempt_number: priorAttempts.length + 1,
answers,
total_points: totalPoints,
earned_points: earnedPoints,
score,
passing_score: quiz.passing_score ?? 70,
passed,
});
return R.success(res, "Quiz submitted.", {
attempt_id: attempt.attempt_id,
attempt_number: attempt.attempt_number,
score,
passed,
passing_score: attempt.passing_score,
total_points: totalPoints,
earned_points: earnedPoints,
attempts_remaining: Math.max(0, status.attempts_remaining - 1),
});
} catch (err) {
console.error("[CLIENT][QUIZ][SUBMIT]", err);
return R.error(res, "Could not submit quiz.", 500);
}
};
exports.submitCourseAssessment = async (req, res) => {
try {
const { courseId, assessmentId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
include: [{ model: QuizOption, as: "options" }],
}],
});
if (!assessment) return R.error(res, "Assessment not found.", 404);
const priorAttempts = await QuizAttempt.findAll({
where: { assessment_id: assessment.assessment_id, user_id },
attributes: ["attempt_id", "score", "passed", "createdAt"],
});
const status = getAttemptStatus(priorAttempts);
if (!status.can_attempt) {
if (status.cooldown_until) {
return R.error(res, "Please wait a bit before retaking this quiz — check the quiz screen for when you can try again.", 429);
}
return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429);
}
const { totalPoints, earnedPoints, score } = gradeSubmission(assessment.questions ?? [], answers);
const passed = score >= (assessment.passing_score ?? 70);
const attempt = await QuizAttempt.create({
user_id,
assessment_id: assessment.assessment_id,
course_id: courseId,
attempt_number: priorAttempts.length + 1,
answers,
total_points: totalPoints,
earned_points: earnedPoints,
score,
passing_score: assessment.passing_score ?? 70,
passed,
});
let course_completed = false;
if (passed) {
course_completed = true;
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ["course_id", "title"] });
const totalCompleted = await QuizAttempt.count({
where: { user_id, passed: true, assessment_id: { [Op.ne]: null } },
distinct: true,
col: "assessment_id",
});
await onCourseCompleted(user_id, courseId, totalCompleted, course?.title ?? null);
}
return R.success(res, "Assessment submitted.", {
attempt_id: attempt.attempt_id,
attempt_number: attempt.attempt_number,
score,
passed,
passing_score: attempt.passing_score,
total_points: totalPoints,
earned_points: earnedPoints,
attempts_remaining: Math.max(0, status.attempts_remaining - 1),
course_completed,
});
} catch (err) {
console.error("[CLIENT][ASSESSMENT][SUBMIT]", err);
return R.error(res, "Could not submit assessment.", 500);
}
};
// ─── UUID LOOKUPS (task requirement detail blocks) ────────────────────────────
exports.getCourseByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const course = await Course.findOne({
where: { uuid, ...notDeleted },
attributes: ["course_id", "uuid", "title", "description", "level", "subscription"],
});
if (!course) return R.error(res, "Course not found.", 404);
return R.success(res, "Course retrieved.", course);
} catch (err) {
console.error("[CLIENT][COURSES][BY UUID]", err);
return R.error(res, "Could not retrieve course.", 500);
}
};
exports.getUnitByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({
where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description"],
include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }],
});
if (!unit) return R.error(res, "Unit not found.", 404);
return R.success(res, "Unit retrieved.", unit);
} catch (err) {
console.error("[CLIENT][UNITS][BY UUID]", err);
return R.error(res, "Could not retrieve unit.", 500);
}
};
exports.getLessonsByUnitUuid = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({
where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description", "order_index"],
include: [
{ model: Course, as: "course", attributes: ["course_id", "title"] },
{
model: Lesson,
as: "lessons",
where: notDeleted,
required: false,
attributes: ["lesson_id", "uuid", "title", "description", "order_index"],
include: [{ model: LessonPage, as: "page", attributes: ["blocks"], required: false }],
order: [["order_index", "ASC"]],
},
],
});
if (!unit) return R.error(res, "Unit not found.", 404);
const lessons = (unit.lessons ?? [])
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map((l) => ({
lesson_id: l.lesson_id,
uuid: l.uuid,
title: l.title,
description: l.description,
order_index: l.order_index ?? 0,
blocks: l.page?.blocks ?? [],
}));
return R.success(res, "Unit lessons retrieved.", {
unit_id: unit.unit_id,
uuid: unit.uuid,
title: unit.title,
description: unit.description,
course: unit.course ?? null,
lessons,
});
} catch (err) {
console.error("[CLIENT][UNITS][LESSONS BY UUID]", err);
return R.error(res, "Could not retrieve unit lessons.", 500);
}
};
exports.getLessonByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const lesson = await Lesson.findOne({
where: { uuid, ...notDeleted },
attributes: ["lesson_id", "uuid", "title", "description"],
include: [
{
model: LessonPage,
as: "page",
attributes: ["blocks"],
required: false,
},
{
model: Unit,
as: "unit",
attributes: ["unit_id", "title", "order_index"],
include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }],
},
],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
const data = {
lesson_id: lesson.lesson_id,
uuid: lesson.uuid,
title: lesson.title,
description: lesson.description,
blocks: lesson.page?.blocks ?? [],
unit: lesson.unit ?? null,
};
return R.success(res, "Lesson retrieved.", data);
} catch (err) {
console.error("[CLIENT][LESSONS][BY UUID]", err);
return R.error(res, "Could not retrieve lesson.", 500);
}
};
+240
View File
@@ -0,0 +1,240 @@
/***********************************************************************************************************************************************************************
* File Name: media.controller.js (client)
* Type of Program: Controller
* Description: Secure media delivery for S3/Garage assets only.
*
* Chibisafe assets use their raw file_url directly — no token needed.
* The block content already has the URL saved at CMS time (handleSelect).
*
* S3 Flow:
* 1. POST /client/media/token { asset_id }
* → validates tier access
* → signs JWT with user_id + IP binding
* → returns { token, provider: "s3", file_type }
*
* 2. Browser sets <video/audio src> = API_BASE + "/client/media/stream/" + token
* → Express verifies JWT
* → Checks IP matches the one that issued the token
* → Generates 60s pre-signed Garage URL, proxies bytes
* → Real S3 URL never reaches the browser
*
* Protection layers:
* 1. JWT signature — token can't be forged
* 2. 5-min TTL — token expires quickly
* 3. IP binding — token is useless if shared with another machine
* 4. Token tracking — tokens are tracked; logged after first use
* (range requests from the same token are allowed
* since the browser reuses the token for seeking)
*
* Supported file_type values: video, audio, document, image
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 12, 2026
***********************************************************************************************************************************************************************/
"use strict";
const https = require("https");
const http = require("http");
const jwt = require("jsonwebtoken");
const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl");
const s3 = require("../../services/s3.service");
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
const TOKEN_TTL_SEC = 4 * 60 * 60; // 4 hours — token must outlive the longest video
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
// ─── In-memory token tracker ──────────────────────────────────────────────────
//
// Tracks tokens that have been used at least once.
// Allows reuse within TTL for range requests (browser seeking reuses the token).
// Auto-cleans after TTL to prevent unbounded memory growth.
// In production with multiple server instances, replace with Redis.
//
const activeTokens = new Map(); // token → { firstUsed, ip }
function trackToken(token, ip) {
if (activeTokens.has(token)) return; // already tracked, allow reuse
activeTokens.set(token, { firstUsed: Date.now(), ip });
setTimeout(() => activeTokens.delete(token), TOKEN_TTL_SEC * 1000);
}
// ─── Helper: resolve client IP ───────────────────────────────────────────────
function resolveIp(req) {
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) return forwarded.split(",")[0].trim();
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
}
// ─── Helper: pipe S3 pre-signed URL to response (Range-aware) ────────────────
function pipeRemoteStream(remoteUrl, req, res) {
const parsed = new URL(remoteUrl);
const transport = parsed.protocol === "https:" ? https : http;
const proxyHeaders = { "User-Agent": "StarrMediaProxy/1.0" };
if (req.headers.range) proxyHeaders["Range"] = req.headers.range;
const proxyReq = transport.request(remoteUrl, { headers: proxyHeaders }, (proxyRes) => {
const status = proxyRes.statusCode === 206 ? 206 : 200;
[
"content-type",
"content-length",
"content-range",
"accept-ranges",
"last-modified",
"etag",
"content-disposition",
].forEach((h) => {
if (proxyRes.headers[h]) res.setHeader(h, proxyRes.headers[h]);
});
res.setHeader("Cache-Control", "no-store");
res.setHeader("X-Content-Type-Options", "nosniff");
res.status(status);
proxyRes.pipe(res);
});
proxyReq.on("error", (err) => {
console.error("[CLIENT][MEDIA][PROXY] Stream error:", err.message);
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
});
req.on("close", () => proxyReq.destroy());
proxyReq.end();
}
// ─── POST /client/media/token ─────────────────────────────────────────────────
//
// S3 assets only — Chibisafe assets use their raw file_url directly.
// Returns: { token, provider: "s3", file_type }
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, "Asset not found.", 404);
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
}
if (asset.storage_provider !== "s3") {
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
}
// ── Bind token to the requester's IP ──────────────────────────────────────
const ip = resolveIp(req);
const token = jwt.sign(
{
asset_id,
user_id: req.user.user_id,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip, // ← IP binding — verified on every stream request
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
// ── Presign thumbnail URL so the browser can load it directly ─────────────
let thumbnail_url = null;
if (asset.thumbnail_storage_key) {
try {
thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC);
} catch {
// Non-fatal — thumbnail is cosmetic
}
}
return R.success(res, "Token issued.", {
token,
provider: "s3",
file_type: asset.file_type,
thumbnail_url,
});
} catch (err) {
console.error("[CLIENT][MEDIA][TOKEN]", err);
return R.error(res, "Could not issue media token.", 500);
}
};
// ─── GET /client/media/stream/:token ─────────────────────────────────────────
//
// Called ONLY by the browser's <video>/<audio>/document element.
// Never called via axios — that would consume the stream as JSON.
//
// Protection checks (in order):
// 1. JWT signature valid
// 2. Token not expired (TTL enforced by JWT)
// 3. Requester IP matches the IP that issued the token
//
// Range requests for the same token are allowed (browser seeking).
exports.streamAsset = async (req, res) => {
const { token } = req.params;
// ── CORS ──────────────────────────────────────────────────────────────────
const allowedOrigin = process.env.FRONTEND_URL ?? "http://localhost:5173";
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Range, Authorization");
res.setHeader("Access-Control-Expose-Headers", "Content-Range, Content-Length, Accept-Ranges, Content-Disposition");
if (req.method === "OPTIONS") return res.sendStatus(204);
// ── Block direct browser navigation ──────────────────────────────────────
// Sec-Fetch-Mode is "navigate" when a user pastes the URL into the address
// bar or opens it in a new tab. Legitimate <video src> requests use "no-cors"
// and fetch() calls use "cors" — both are allowed.
const fetchMode = req.headers["sec-fetch-mode"];
if (fetchMode === "navigate") {
return res.status(401).json({ message: "Unauthorized." });
}
// ── Verify JWT ────────────────────────────────────────────────────────────
let payload;
try {
payload = jwt.verify(token, MEDIA_SECRET);
} catch {
return res.status(401).json({ message: "Invalid or expired media token." });
}
const { storage_key, ip: tokenIp } = payload;
if (!storage_key) return res.status(401).json({ message: "Unauthorized." });
// ── IP binding check ──────────────────────────────────────────────────────
const requestIp = resolveIp(req);
if (tokenIp && requestIp !== tokenIp) {
console.warn(`[CLIENT][MEDIA][STREAM] IP mismatch — token: ${tokenIp}, request: ${requestIp}`);
return res.status(403).json({ message: "Token IP mismatch." });
}
// ── Track token (allow reuse for range requests) ──────────────────────────
trackToken(token, requestIp);
// ── Generate pre-signed URL and proxy bytes ───────────────────────────────
let presignedUrl;
try {
presignedUrl = await s3.getSignedDownloadUrl(storage_key, 60);
} catch (err) {
console.error("[CLIENT][MEDIA][STREAM] Pre-sign failed:", err.message);
return res.status(500).json({ message: "Could not resolve media stream." });
}
return pipeRemoteStream(presignedUrl, req, res);
};
@@ -0,0 +1,86 @@
/***********************************************************************************************************************************************************************
* File Name : notification.controller.js
* Type : Controller (Client)
* Description : Per-user notification management.
* GET /client/notifications — paginated list for the auth user
* GET /client/notifications/unseen — unseen count
* PATCH /client/notifications/:id/seen — mark one as seen
* PATCH /client/notifications/seen-all — mark all as seen
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const UserNotification = require('../../models/notifications/user_notification.mdl');
const R = require('../../utils/response.util');
// ─── GET /client/notifications ────────────────────────────────────────────────
async function list(req, res) {
try {
const userId = req.user.user_id;
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 UserNotification.findAndCountAll({
where: { user_id: userId },
order: [['createdAt', 'DESC']],
limit,
offset,
});
return R.success(res, 'Notifications fetched.', {
notifications: rows,
pagination: { page, limit, total: count, pages: Math.ceil(count / limit) },
});
} catch (err) {
console.error('[CLIENT NOTIFICATION] list error:', err);
return R.error(res, 'Failed to fetch notifications.');
}
}
// ─── GET /client/notifications/unseen ────────────────────────────────────────
async function unseenCount(req, res) {
if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null });
try {
const count = await UserNotification.count({
where: { user_id: req.user.user_id, seen: false },
});
return R.success(res, 'Unseen count fetched.', { count });
} catch (err) {
console.error('[CLIENT NOTIFICATION] unseenCount error:', err);
return R.error(res, 'Failed to fetch unseen count.');
}
}
// ─── PATCH /client/notifications/:id/seen ────────────────────────────────────
async function markSeen(req, res) {
try {
const notification = await UserNotification.findOne({
where: { notification_id: req.params.id, user_id: req.user.user_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('[CLIENT NOTIFICATION] markSeen error:', err);
return R.error(res, 'Failed to mark notification as seen.');
}
}
// ─── PATCH /client/notifications/seen-all ────────────────────────────────────
async function markAllSeen(req, res) {
try {
const now = new Date();
const [count] = await UserNotification.update(
{ seen: true, seen_at: now },
{ where: { user_id: req.user.user_id, seen: false } }
);
return R.success(res, `${count} notification(s) marked as seen.`, { count });
} catch (err) {
console.error('[CLIENT NOTIFICATION] markAllSeen error:', err);
return R.error(res, 'Failed to mark all notifications as seen.');
}
}
module.exports = { list, unseenCount, markSeen, markAllSeen };
+95 -7
View File
@@ -1,23 +1,28 @@
/***********************************************************************************************************************************************************************
* File Name: profile.controller.js (client)
* Type of Program: Controller
* Description: Self-service profile management for CLIENT users.
* Description: Self-service profile management for all end users.
* All routes require: authenticate → requireClient()
*
* Endpoints:
* GET /api/client/profile → view own profile
* PUT /api/client/profile → update personal_info
* GET /api/client/sessions → view own active sessions
* DELETE /api/client/sessions/:id → revoke a specific session
* GET /api/client/profile → view own profile
* PUT /api/client/profile → update personal_info
* GET /api/client/sessions → view own active sessions
* DELETE /api/client/sessions/:id → revoke a specific session
* GET /api/client/achievements → view own achievements
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
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 logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { uploadFile, deleteFile } = require('../../services/s3.service');
// ─── GET own profile ───────────────────────────────────────────────────────────
exports.getProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id, {
@@ -30,6 +35,7 @@ exports.getProfile = async (req, res) => {
};
// ─── PUT update own profile ────────────────────────────────────────────────────
exports.updateProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
@@ -51,6 +57,8 @@ exports.updateProfile = async (req, res) => {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
logActivity(req.user.user_id, 'update_profile');
return R.success(res, 'Profile updated.', updated);
} catch (err) {
console.error('[CLIENT] updateProfile error:', err);
@@ -59,11 +67,12 @@ exports.updateProfile = async (req, res) => {
};
// ─── GET own sessions ──────────────────────────────────────────────────────────
exports.getSessions = async (req, res) => {
try {
const sessions = await mdl_UserSessions.findAll({
where: { user_id: req.user.user_id, is_active: true },
order: [['createdAt', 'DESC']],
where: { user_id: req.user.user_id, is_active: true },
order: [['createdAt', 'DESC']],
attributes: { exclude: ['refresh_token_hash'] },
});
return R.success(res, 'Sessions retrieved.', sessions);
@@ -73,6 +82,7 @@ exports.getSessions = async (req, res) => {
};
// ─── DELETE revoke a session ───────────────────────────────────────────────────
exports.revokeSession = async (req, res) => {
try {
const session = await mdl_UserSessions.findOne({
@@ -85,8 +95,86 @@ exports.revokeSession = async (req, res) => {
logout_info: { date: new Date().toISOString(), ip_address: req.ip },
});
logActivity(req.user.user_id, 'revoke_session', { entityType: 'session', entityId: session.session_id });
return R.success(res, 'Session revoked.');
} catch (err) {
return R.error(res, 'Could not revoke session.', 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);
// Remove old avatar from S3 before replacing
const oldKey = user.personal_info?.avatar?.uuid;
if (oldKey) await deleteFile(oldKey).catch(() => {});
const { url, uuid } = await uploadFile({
buffer: req.file.buffer,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
ownerType: 'avatar',
});
const merged = {
...(user.personal_info || {}),
avatar: {
url,
uuid,
name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
},
};
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.', updated);
} catch (err) {
console.error('[CLIENT] 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);
const key = user.personal_info?.avatar?.uuid;
if (!key) return R.error(res, 'No avatar to remove.', 404);
await deleteFile(key).catch(() => {});
const merged = { ...(user.personal_info || {}), avatar: null };
await user.update({ personal_info: merged });
return R.success(res, 'Avatar removed.');
} catch (err) {
console.error('[CLIENT] deleteAvatar error:', err);
return R.error(res, 'Could not remove avatar.', 500);
}
};
// ─── GET own achievements ──────────────────────────────────────────────────────
exports.getAchievements = async (req, res) => {
try {
const achievements = await mdl_Achievements.findAll({
where: { user_id: req.user.user_id },
order: [['granted_at', 'DESC']],
});
return R.success(res, 'Achievements retrieved.', achievements);
} catch (err) {
return R.error(res, 'Could not retrieve achievements.', 500);
}
};
+682
View File
@@ -0,0 +1,682 @@
/***********************************************************************************************************************************************************************
* File Name: task.controller.js (client)
* Type of Program: Controller
* Description: Client-level task access.
* Users can view groups they belong to, task lists assigned to those
* groups, tasks within those lists, and submit work for tasks.
* Read-only except for completions.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const sequelize = require('../../config/db.config');
const { Task, TaskList, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const { userExclude } = require('../../models/task/task.attributes');
const { clientExclude } = require('../../models/task/task_completion.attributes');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
// =============================================================================
// ── GROUPS ────────────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET MY GROUPS ────────────────────────────────────────────────────────────
// GET /client/groups
// Returns all active groups the authenticated user belongs to.
exports.getMyGroups = async (req, res) => {
try {
const groups = await mdl_UserGroups.findAll({
include: [
{
model: mdl_Users,
as: 'members',
where: { user_id: req.user.user_id },
attributes: [],
through: {
model: mdl_UserGroupMembers,
attributes: ['joined_at'],
where: { deletedAt: null },
},
},
],
where: { is_active: true },
attributes: ['group_id', 'name', 'group_code', 'description'],
order: [['name', 'ASC']],
});
return R.success(res, 'Groups retrieved.', groups);
} catch (err) {
console.error('[CLIENT][GET MY GROUPS]', err);
return R.error(res, 'Could not retrieve groups.', 500);
}
};
// ─── GET ONE GROUP ────────────────────────────────────────────────────────────
// GET /client/groups/:groupId
// Returns group info — verifies the user is a member before responding.
exports.getMyGroup = async (req, res) => {
try {
const { groupId } = req.params;
const group = await mdl_UserGroups.findOne({
where: { group_id: groupId, is_active: true },
attributes: ['group_id', 'name', 'group_code', 'description'],
include: [
{
model: mdl_Users,
as: 'members',
where: { user_id: req.user.user_id },
attributes: [],
through: {
model: mdl_UserGroupMembers,
attributes: [],
where: { deletedAt: null },
},
},
],
});
if (!group) return R.error(res, 'Group not found or you are not a member.', 404);
return R.success(res, 'Group retrieved.', group);
} catch (err) {
console.error('[CLIENT][GET MY GROUP]', err);
return R.error(res, 'Could not retrieve group.', 500);
}
};
// =============================================================================
// ── TASK LISTS ────────────────────────────────────────────────────────────────
// =============================================================================
// ─── Helper: verify user is member of group ───────────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
//
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|done|overdue
//
// has_completed is now computed per-task as: ALL of the task's requirements
// individually have a completion signal — matching RequirementsStatusPanel's
// "Overall progress: X / Y done" logic exactly.
//
// Per-requirement-type completion:
// upload_file → task has at least one TaskCompletion (binary, task-level)
// visit_link → a TaskLinkVisit exists for THIS requirement_id
// read_course/
// read_unit/
// read_lesson → a TaskProgress with completed=true exists for THIS
// requirement_id (+ reference_id)
//
// Task bucket:
// done → every requirement passes its check above
// (a task with zero requirements is vacuously "ongoing", per
// earlier spec — zero requirements should not normally happen)
// overdue → not done AND task.deadline < now
// ongoing → otherwise
// ─────────────────────────────────────────────────────────────────────────────
exports.getGroupTaskList = async (req, res) => {
try {
const { groupId, taskListId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue'
const userId = req.user.user_id;
const member = await isMember(userId, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const taskList = await TaskList.findOne({
where: { task_list_id: taskListId },
attributes: { exclude: userExclude },
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
{
model: Task,
as: 'tasks',
required: false,
attributes: { exclude: userExclude },
include: [{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
}],
order: [['createdAt', 'ASC']],
},
],
});
if (!taskList) return R.error(res, 'Task list not found or not assigned to your group.', 404);
const json = taskList.toJSON();
const tasks = json.tasks ?? [];
const taskIds = tasks.map((t) => t.task_id);
// ── Fetch user's completion signals for these tasks ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([
taskIds.length
? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id'],
})
: [],
taskIds.length
? TaskLinkVisit.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'requirement_id'],
})
: [],
taskIds.length
? TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['task_id', 'requirement_id', 'reference_id'],
})
: [],
]);
// ── Lookup sets/maps for per-requirement checks ─────────────────────────
const tasksWithCompletion = new Set(completions.map((c) => c.task_id));
const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id));
const completedProgressKeys = new Set(
progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)
);
const now = Date.now();
// ── Bucket each task by per-requirement completion ──────────────────────
const bucketedTasks = tasks.map((task) => {
const requirements = task.requirements ?? [];
const allRequirementsDone = requirements.length > 0 && requirements.every((r) => {
switch (r.type) {
case 'upload_file':
return tasksWithCompletion.has(task.task_id);
case 'visit_link':
return visitedRequirementIds.has(r.requirement_id);
case 'read_course':
case 'read_unit':
case 'read_lesson':
return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
default:
return true; // unknown requirement types don't block completion
}
});
const has_completed = allRequirementsDone;
let bucket;
if (has_completed) {
bucket = 'done';
} else if (task.deadline && new Date(task.deadline).getTime() < now) {
bucket = 'overdue';
} else {
bucket = 'ongoing';
}
return { ...task, has_completed, _bucket: bucket };
});
// ── Filter by requested status, strip internal _bucket field ──────────
const filteredTasks = status
? bucketedTasks.filter((t) => t._bucket === status)
: bucketedTasks;
json.tasks = filteredTasks.map(({ _bucket, ...rest }) => rest);
return R.success(res, 'Task list retrieved.', json);
} catch (err) {
console.error('[CLIENT][GET GROUP TASK LIST]', err);
return R.error(res, 'Could not retrieve task list.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskLists in task.controller.js (client) — plural
//
// GET /client/groups/:groupId/task-lists?status=ongoing|done|overdue
//
// Updated to match getGroupTaskList (singular): has_completed per task now
// means ALL of that task's requirements individually have a completion signal
// (not just "any"), matching RequirementsStatusPanel's "X / Y done" logic.
//
// TaskList bucket (based on per-task has_completed, computed below):
// TaskList has zero tasks → Ongoing (nothing to do yet)
// ALL tasks have has_completed → Done
// NOT all done AND any incomplete
// task has deadline < now → Overdue
// Otherwise → Ongoing
// ─────────────────────────────────────────────────────────────────────────────
exports.getGroupTaskLists = async (req, res) => {
try {
const { groupId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue'
const userId = req.user.user_id;
const member = await isMember(userId, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
// ── Fetch ALL task lists assigned to this group, no status filter ─────
const taskLists = await TaskList.findAll({
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
{
model: Task,
as: 'tasks',
required: false,
attributes: { exclude: userExclude },
include: [
{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
},
],
order: [['createdAt', 'ASC']],
},
],
attributes: { exclude: userExclude },
order: [['createdAt', 'ASC']],
});
// ── Gather all task_ids across the group's task lists ──────────────────
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
const taskIds = allTasks.map((t) => t.task_id);
// ── Fetch user's completion signals for these tasks ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([
taskIds.length
? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id'],
})
: [],
taskIds.length
? TaskLinkVisit.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'requirement_id'],
})
: [],
taskIds.length
? TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['task_id', 'requirement_id', 'reference_id'],
})
: [],
]);
// ── Lookup sets/maps for per-requirement checks ─────────────────────────
const tasksWithCompletion = new Set(completions.map((c) => c.task_id));
const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id));
const completedProgressKeys = new Set(
progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)
);
const now = Date.now();
// ── Compute per-task has_completed via per-requirement checks ──────────
const computeHasCompleted = (task) => {
const requirements = task.requirements ?? [];
if (requirements.length === 0) return false; // vacuously not done
return requirements.every((r) => {
switch (r.type) {
case 'upload_file':
return tasksWithCompletion.has(task.task_id);
case 'visit_link':
return visitedRequirementIds.has(r.requirement_id);
case 'read_course':
case 'read_unit':
case 'read_lesson':
return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
default:
return true;
}
});
};
// ── Bucket each task list based on per-task has_completed ───────────────
const bucketed = taskLists.map((tl) => {
const json = tl.toJSON();
const tasks = json.tasks ?? [];
tasks.forEach((task) => {
task.has_completed = computeHasCompleted(task);
});
let bucket;
if (tasks.length === 0) {
bucket = 'ongoing';
} else {
const allDone = tasks.every((t) => t.has_completed);
if (allDone) {
bucket = 'done';
} else {
const anyOverdue = tasks.some((t) =>
!t.has_completed && t.deadline && new Date(t.deadline).getTime() < now
);
bucket = anyOverdue ? 'overdue' : 'ongoing';
}
}
return { ...json, tasks, _bucket: bucket };
});
// ── Filter by requested status, then strip internal _bucket field ──────
const filtered = status
? bucketed.filter((tl) => tl._bucket === status)
: bucketed;
const data = filtered.map(({ _bucket, ...rest }) => rest);
return R.success(res, 'Task lists retrieved.', data);
} catch (err) {
console.error('[CLIENT][GET GROUP TASK LISTS]', err);
return R.error(res, 'Could not retrieve task lists.', 500);
}
};
// =============================================================================
// ── TASKS ─────────────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET ONE TASK ─────────────────────────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId
//
// Returns the task with its requirements + the user's latest completion.
exports.getTask = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
attributes: { exclude: userExclude },
include: [
{
model: TaskList,
as: 'taskList',
attributes: { exclude: userExclude },
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
],
},
{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
},
{
// Latest completion by this user
model: TaskCompletion,
as: 'completions',
where: { user_id: req.user.user_id },
required: false,
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
separate: true,
order: [['createdAt', 'ASC']],
}],
order: [['submitted_at', 'DESC']],
limit: 1,
},
],
});
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
// Flatten: expose latest_completion directly instead of array
const data = task.toJSON();
data.latest_completion = data.completions?.[0] ?? null;
delete data.completions;
return R.success(res, 'Task retrieved.', data);
} catch (err) {
console.error('[CLIENT][GET TASK]', err);
return R.error(res, 'Could not retrieve task.', 500);
}
};
// =============================================================================
// ── SUBMISSIONS ───────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET MY SUBMISSIONS FOR A TASK ───────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions
// Returns all past completions by this user for this task (newest first).
exports.getMySubmissions = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) return R.error(res, 'Task not found.', 404);
const completions = await TaskCompletion.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
}],
order: [['submitted_at', 'DESC']],
});
return R.success(res, 'Completions retrieved.', completions);
} catch (err) {
console.error('[CLIENT][GET MY SUBMISSIONS]', err);
return R.error(res, 'Could not retrieve completions.', 500);
}
};
// ─── SUBMIT ───────────────────────────────────────────────────────────────────
//
// Adds validation against the task's `upload_file` TaskRequirement:
// - allowed_file_types: array of uppercase extensions (e.g. ["PDF","DOCX",...])
// - max_file_count: integer cap on number of files per completion
//
// Validation happens BEFORE creating the TaskCompletion row, at submit time only
// (not at /upload). If validation fails, the transaction is rolled back and a
// 400 is returned — the already-uploaded files remain orphaned in S3, which is
// acceptable per current design (no cleanup-on-reject requirement).
exports.submitTask = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId } = req.params;
const { note, files = [] } = req.body;
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
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); }
if (!files.length) {
await t.rollback();
return R.error(res, 'At least one file is required to submit.', 400);
}
// Validate file entries have required fields
const invalid = files.some((f) => !f.file_url || !f.file_name);
if (invalid) {
await t.rollback();
return R.error(res, 'Each file must have file_url and file_name.', 400);
}
// ── Validate against upload_file requirement (if defined) ──────────────
const uploadRequirement = await TaskRequirement.findOne({
where: { task_id: taskId, type: 'upload_file' },
transaction: t,
});
if (uploadRequirement) {
// ── max_file_count ───────────────────────────────────────────────────
const maxFiles = uploadRequirement.max_file_count;
if (maxFiles && files.length > maxFiles) {
await t.rollback();
return R.error(
res,
`You can only submit up to ${maxFiles} file${maxFiles !== 1 ? 's' : ''} for this task.`,
400
);
}
// ── allowed_file_types ───────────────────────────────────────────────
const allowedTypes = (uploadRequirement.allowed_file_types ?? [])
.map((ext) => String(ext).toUpperCase());
if (allowedTypes.length) {
const rejected = files.filter((f) => {
const ext = (f.file_name.split('.').pop() ?? '').toUpperCase();
return !allowedTypes.includes(ext);
});
if (rejected.length) {
await t.rollback();
const rejectedNames = rejected.map((f) => f.file_name).join(', ');
return R.error(
res,
`These files are not allowed: ${rejectedNames}. Allowed types: ${allowedTypes.join(', ')}.`,
400
);
}
}
}
// ── Create completion ──────────────────────────────────────────────────
const completion = await TaskCompletion.create({
task_id: taskId,
user_id: req.user.user_id,
note: note || null,
submitted_at: new Date(),
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}, { transaction: t });
const fileRows = files.map((f) => ({
completion_id: completion.completion_id,
file_url: f.file_url,
file_name: f.file_name,
file_size: f.file_size ?? null,
mime_type: f.mime_type ?? null,
storage_key: f.storage_key ?? null,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}));
await TaskCompletionFile.bulkCreate(fileRows, { transaction: t });
await t.commit();
// Return full completion with files
const full = await TaskCompletion.findByPk(completion.completion_id, {
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
}],
});
logActivity(req.user.user_id, 'submit_task', {
entityType: 'task',
entityId: Number(taskId),
});
return R.success(res, 'Task submitted successfully.', full, 201);
} catch (err) {
await t.rollback();
console.error('[CLIENT][SUBMIT TASK]', err);
return R.error(res, 'Could not submit task.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// ADD THIS to the bottom of task.controller.js (client)
// ─────────────────────────────────────────────────────────────────────────────
// ─── GET LATEST COMPLETION ────────────────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/latest
// Returns only the most recent completion for this user on this task.
// Returns null if the user has not submitted yet — that is valid.
exports.getLatestCompletion = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
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: { task_id: taskId, user_id: req.user.user_id },
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
separate: true,
order: [['createdAt', 'ASC']],
}],
order: [['submitted_at', 'DESC']],
});
return R.success(res, 'Latest completion retrieved.', completion ?? null);
} catch (err) {
console.error('[CLIENT][GET LATEST COMPLETION]', err);
return R.error(res, 'Could not retrieve latest completion.', 500);
}
};
@@ -0,0 +1,172 @@
/***********************************************************************************************************************************************************************
* File Name: task_download.controller.js (client)
* Type of Program: Controller
* Description: Proxies file downloads for task completion attachments through
* the backend, so the raw Garage/S3 URL is never exposed to the
* browser. Sets Content-Disposition: attachment with the original
* filename.
*
* storage_key is DERIVED from file_url at request time (no schema
* change needed) by stripping the known S3_PUBLIC_URL + bucket
* prefix, since both are constants defined in s3.service.js / .env.
*
* Route: GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/:completionId/files/:fileId/download
*
* Access: only the completion's owner (req.user.user_id === completion.user_id)
* can download — admin downloads go through a separate admin route.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 15, 2026
***********************************************************************************************************************************************************************/
const { Task, TaskList, TaskListGroup } = require('../../models/task/task.mdl');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { getObjectStream } = require('../../services/s3.service');
const R = require('../../utils/response.util');
// ─── Helper: verify user is a member of the group ─────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─── Helper: derive S3 storage_key from a public file_url ─────────────────────
// Strips "{S3_PUBLIC_URL}/{S3_BUCKET}/" prefix, leaving e.g. "images/uuid.jpg"
const deriveStorageKey = (fileUrl) => {
const publicUrl = (process.env.S3_PUBLIC_URL || '').replace(/\/$/, '');
const bucket = process.env.S3_BUCKET || 'philproperties';
const prefix = `${publicUrl}/${bucket}/`;
if (fileUrl && fileUrl.startsWith(prefix)) {
return fileUrl.slice(prefix.length);
}
return null;
};
// =============================================================================
// ── STREAM FILE (inline preview — no Content-Disposition: attachment) ────────
// =============================================================================
//
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/:completionId/files/:fileId/stream
//
// Used by FilePreview.jsx for <img>/<video>/<audio>/<iframe> src — proxies the
// object inline so the raw Garage/S3 URL never appears, but does NOT force
// download (no Content-Disposition: attachment).
exports.streamCompletionFile = async (req, res) => {
try {
const { groupId, taskListId, taskId, completionId, fileId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
include: [{
model: TaskList,
as: 'taskList',
required: true,
include: [{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
required: true,
attributes: [],
through: { model: TaskListGroup, attributes: [] },
}],
}],
});
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
const completion = await TaskCompletion.findOne({
where: { completion_id: completionId, task_id: taskId, user_id: req.user.user_id },
});
if (!completion) return R.error(res, 'Completion not found.', 404);
const file = await TaskCompletionFile.findOne({
where: { file_id: fileId, completion_id: completionId },
});
if (!file) return R.error(res, 'File not found.', 404);
const storageKey = deriveStorageKey(file.file_url);
if (!storageKey) {
return R.error(res, 'This file cannot be previewed (unrecognized storage URL).', 422);
}
const { stream, contentType, contentLength } = await getObjectStream(storageKey);
res.setHeader('Content-Type', contentType || file.mime_type || 'application/octet-stream');
if (contentLength) res.setHeader('Content-Length', contentLength);
// No Content-Disposition — browser renders inline based on Content-Type
stream.pipe(res);
} catch (err) {
console.error('[CLIENT][STREAM COMPLETION FILE]', err);
return R.error(res, 'Could not load file.', 500);
}
};
// =============================================================================
// ── DOWNLOAD FILE ──────────────────────────────────────────────────────────────
// =============================================================================
exports.downloadCompletionFile = async (req, res) => {
try {
const { groupId, taskListId, taskId, completionId, fileId } = req.params;
// ── Validate member ───────────────────────────────────────────────────
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
// ── Validate task belongs to task list + group ────────────────────────
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
include: [{
model: TaskList,
as: 'taskList',
required: true,
include: [{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
required: true,
attributes: [],
through: { model: TaskListGroup, attributes: [] },
}],
}],
});
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
// ── Validate completion belongs to this user + task ───────────────────
const completion = await TaskCompletion.findOne({
where: { completion_id: completionId, task_id: taskId, user_id: req.user.user_id },
});
if (!completion) return R.error(res, 'Completion not found.', 404);
// ── Validate file belongs to completion ───────────────────────────────
const file = await TaskCompletionFile.findOne({
where: { file_id: fileId, completion_id: completionId },
});
if (!file) return R.error(res, 'File not found.', 404);
// ── Derive storage_key from file_url ──────────────────────────────────
const storageKey = deriveStorageKey(file.file_url);
if (!storageKey) {
return R.error(res, 'This file cannot be downloaded (unrecognized storage URL).', 422);
}
// ── Stream from S3/Garage ──────────────────────────────────────────────
const { stream, contentType, contentLength } = await getObjectStream(storageKey);
res.setHeader('Content-Type', contentType || file.mime_type || 'application/octet-stream');
if (contentLength) res.setHeader('Content-Length', contentLength);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.file_name)}"`);
stream.pipe(res);
} catch (err) {
console.error('[CLIENT][DOWNLOAD COMPLETION FILE]', err);
return R.error(res, 'Could not download file.', 500);
}
};
@@ -0,0 +1,371 @@
/***********************************************************************************************************************************************************************
* File Name: task_progress.controller.js (client)
* Type of Program: Controller
* Description: Client-side progress tracking via UPSERT for all requirement types.
*
* GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress
* → returns full progress snapshot: { link_visits, progress }
*
* POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
* → UPSERT TaskLinkVisit (visit_link)
*
* POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress
* → UPSERT TaskProgress (read_lesson) + derives read_unit + read_course
*
* UPSERT keys:
* TaskLinkVisit : (requirement_id, user_id)
* TaskProgress : (requirement_id, user_id, reference_id)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const sequelize = require('../../config/db.config');
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
// ─── Helper: verify user is member of group ───────────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─── Helper: verify requirement belongs to task ───────────────────────────────
const getRequirement = async (requirementId, taskId) => {
return TaskRequirement.findOne({
where: { requirement_id: requirementId, task_id: taskId },
});
};
// ─── Helper: derive unit completion ──────────────────────────────────────────
// Unit is complete when ALL read_lesson progress rows under this unit requirement
// for this user are marked completed.
const deriveUnitCompletion = async (userId, unitRequirementId, t) => {
const rows = await TaskProgress.findAll({
where: {
requirement_id: unitRequirementId,
user_id: userId,
type: 'read_lesson',
},
transaction: t,
});
if (!rows.length) return false;
return rows.every((r) => r.completed);
};
// ─── Helper: derive course completion ────────────────────────────────────────
// Course is complete when ALL read_unit progress rows under this course requirement
// for this user are marked completed.
const deriveCourseCompletion = async (userId, courseRequirementId, t) => {
const rows = await TaskProgress.findAll({
where: {
requirement_id: courseRequirementId,
user_id: userId,
type: 'read_unit',
},
transaction: t,
});
if (!rows.length) return false;
return rows.every((r) => r.completed);
};
// =============================================================================
// ── GET FULL PROGRESS SNAPSHOT ────────────────────────────────────────────────
// =============================================================================
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress
// Called once on ViewTaskDetails mount.
// Returns { link_visits: [], progress: [] } — frontend builds lookup maps from these.
exports.getTaskProgress = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
});
if (!task) return R.error(res, 'Task not found.', 404);
const [linkVisits, progress] = await Promise.all([
TaskLinkVisit.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: ['visit_id', 'requirement_id', 'visited_at'],
}),
TaskProgress.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: ['progress_id', 'requirement_id', 'reference_id', 'type', 'completed', 'completed_at'],
}),
]);
return R.success(res, 'Task progress retrieved.', { link_visits: linkVisits, progress });
} catch (err) {
console.error('[CLIENT][GET TASK PROGRESS]', err);
return R.error(res, 'Could not retrieve task progress.', 500);
}
};
// =============================================================================
// ── VISIT LINK (UPSERT) ───────────────────────────────────────────────────────
// =============================================================================
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
//
// UPSERT on (requirement_id, user_id):
// First visit → INSERT new row
// Revisit → UPDATE visited_at to NOW()
exports.visitLink = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId, requirementId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
const requirement = await getRequirement(requirementId, taskId);
if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); }
if (requirement.type !== 'visit_link') {
await t.rollback();
return R.error(res, 'Requirement is not a visit_link type.', 400);
}
const now = new Date();
const [record, created] = await TaskLinkVisit.upsert(
{
task_id: taskId,
requirement_id: requirementId,
user_id: req.user.user_id,
visited_at: now,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
},
{
conflictFields: ['requirement_id', 'user_id'],
returning: true,
transaction: t,
}
);
await t.commit();
if (created) {
logActivity(req.user.user_id, 'visit_link', {
entityType: 'task',
entityId: Number(taskId),
details: { requirement_id: requirementId },
});
}
return R.success(
res,
created ? 'Link visited.' : 'Link visit updated.',
{ requirement_id: requirementId, visited_at: now },
created ? 201 : 200
);
} catch (err) {
await t.rollback();
console.error('[CLIENT][VISIT LINK]', err);
return R.error(res, 'Could not record link visit.', 500);
}
};
// =============================================================================
// ── UPDATE LESSON PROGRESS (UPSERT — derives unit + course) ──────────────────
// =============================================================================
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress
//
// Body:
// {
// reference_id : UUID — lesson_id being marked
// completed : boolean
// unit_requirement_id? : UUID — read_unit requirement this lesson belongs to
// course_requirement_id?: UUID — read_course requirement this unit belongs to
// }
//
// Flow:
// 1. UPSERT lesson progress row
// 2. If unit_requirement_id provided → derive unit completion → UPSERT unit row
// 3. If course_requirement_id provided → derive course completion → UPSERT course row
exports.updateProgress = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId, requirementId } = req.params;
const { reference_id, completed, unit_requirement_id, course_requirement_id } = req.body;
if (!reference_id || completed === undefined) {
await t.rollback();
return R.error(res, 'reference_id and completed are required.', 400);
}
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
const requirement = await getRequirement(requirementId, taskId);
if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); }
const ALLOWED = ['read_lesson', 'read_unit', 'read_course'];
if (!ALLOWED.includes(requirement.type)) {
await t.rollback();
return R.error(res, `Cannot update progress for requirement type: ${requirement.type}.`, 400);
}
const now = new Date();
const userId = req.user.user_id;
// ── Direct UPSERT for read_unit / read_course ─────────────────────────
if (requirement.type === 'read_unit' || requirement.type === 'read_course') {
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: requirementId,
user_id: userId,
reference_id,
type: requirement.type,
completed: !!completed,
completed_at: completed ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
await t.commit();
return R.success(res, 'Progress updated.', {
requirement_id: requirementId,
reference_id,
completed: !!completed,
completed_at: completed ? now : null,
});
}
// ── 1. UPSERT lesson ──────────────────────────────────────────────────
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: requirementId,
user_id: userId,
reference_id,
type: 'read_lesson',
completed: !!completed,
completed_at: completed ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
// ── 2. Derive + UPSERT unit ───────────────────────────────────────────
if (unit_requirement_id) {
const unitReq = await getRequirement(unit_requirement_id, taskId);
if (unitReq && unitReq.type === 'read_unit') {
const unitDone = await deriveUnitCompletion(userId, unit_requirement_id, t);
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: unit_requirement_id,
user_id: userId,
reference_id: unitReq.reference_id,
type: 'read_unit',
completed: unitDone,
completed_at: unitDone ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
// ── 3. Derive + UPSERT course ─────────────────────────────────
if (course_requirement_id) {
const courseReq = await getRequirement(course_requirement_id, taskId);
if (courseReq && courseReq.type === 'read_course') {
const courseDone = await deriveCourseCompletion(userId, course_requirement_id, t);
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: course_requirement_id,
user_id: userId,
reference_id: courseReq.reference_id,
type: 'read_course',
completed: courseDone,
completed_at: courseDone ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
}
}
}
}
await t.commit();
return R.success(res, 'Progress updated.', {
requirement_id: requirementId,
reference_id,
completed: !!completed,
completed_at: completed ? now : null,
});
} catch (err) {
await t.rollback();
console.error('[CLIENT][UPDATE PROGRESS]', err);
return R.error(res, 'Could not update progress.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// NOTE: getLatestCompletion lives in task.controller.js as it shares
// the isMember + Task lookup pattern already established there.
// Add this function to the BOTTOM of task.controller.js:
//
// exports.getLatestCompletion = async (req, res) => {
// try {
// const { groupId, taskListId, taskId } = req.params;
//
// const member = await isMember(req.user.user_id, groupId);
// if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
//
// 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: { task_id: taskId, user_id: req.user.user_id },
// attributes: { exclude: clientExclude },
// include: [{
// model: TaskCompletionFile,
// as: 'files',
// attributes: { exclude: clientExclude },
// separate: true,
// order: [['createdAt', 'ASC']],
// }],
// order: [['submitted_at', 'DESC']],
// });
//
// return R.success(res, 'Latest completion retrieved.', completion ?? null);
// } catch (err) {
// console.error('[CLIENT][GET LATEST COMPLETION]', err);
// return R.error(res, 'Could not retrieve latest completion.', 500);
// }
// };
@@ -0,0 +1,118 @@
/***********************************************************************************************************************************************************************
* File Name: task_upload.controller.js (client)
* Type of Program: Controller
* Description: Handles file uploads for task completion attachments.
* Files are uploaded to S3 (Garage) via s3.service.js.
* Returns file metadata for use in the completion submit payload.
*
* This is intentionally separate from the completion submit endpoint
* so the client can upload files first, then submit completion with
* the returned file references — matching the two-step flow in
* ViewTaskDetails.jsx handleSubmit().
*
* Route: POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/upload
*
* Author: rgrgogu
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { Task, TaskList, TaskListGroup } = require('../../models/task/task.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { uploadFile } = require('../../services/s3.service');
const R = require('../../utils/response.util');
// ─── Helper: verify user is a member of the group ─────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─── Helper: verify task belongs to task list AND is assigned to this group ───
const getAccessibleTask = async (groupId, taskListId, taskId) => {
return Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
include: [
{
model: TaskList,
as: 'taskList',
required: true,
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
required: true,
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
],
},
],
});
};
// ─── Resolve S3 ownerType from mime type ──────────────────────────────────────
const resolveOwnerType = (mimetype = '') => {
if (mimetype.startsWith('image/')) return 'image';
if (mimetype.startsWith('video/')) return 'video';
if (mimetype.startsWith('audio/')) return 'audio';
return 'document';
};
// =============================================================================
// ── UPLOAD FILE ───────────────────────────────────────────────────────────────
// =============================================================================
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/upload
//
// Accepts: multipart/form-data
// file — single file field (multer attaches to req.file)
//
// Returns:
// {
// file_url : "https://garage.philproperties.com/philproperties/documents/uuid.pdf",
// file_name : "social_media_slides.pdf",
// file_size : 2400000,
// mime_type : "application/pdf",
// storage_key: "documents/uuid.pdf" ← for admin reference / future delete
// }
exports.uploadTaskFile = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
// ── Validate member ───────────────────────────────────────────────────
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
// ── Validate task accessibility ───────────────────────────────────────
const task = await getAccessibleTask(groupId, taskListId, taskId);
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
// ── Validate file presence ────────────────────────────────────────────
if (!req.file) return R.error(res, 'No file provided.', 400);
const { buffer, originalname, mimetype, size } = req.file;
const ownerType = resolveOwnerType(mimetype);
// ── Upload to S3 ──────────────────────────────────────────────────────
const { url, uuid: storage_key } = await uploadFile({
buffer,
originalname,
mimetype,
ownerType,
});
return R.success(res, 'File uploaded successfully.', {
file_url: url,
file_name: originalname,
file_size: size,
mime_type: mimetype,
storage_key,
}, 201);
} catch (err) {
console.error('[CLIENT][UPLOAD TASK FILE]', err);
return R.error(res, 'Could not upload file.', 500);
}
};
+340
View File
@@ -0,0 +1,340 @@
/***********************************************************************************************************************************************************************
* File Name: tiers.controller.js (client)
* Type of Program: Controller
* Description: User-facing tier and payment endpoints.
* - View active tier + history
* - Browse active plans (with courses per plan)
* - PayPal redirect checkout (create order → capture → cancel)
* - View own payment history
* Author: rgrgogu
* Date Created: Jun. 6, 2026
* Modified: Jun. 9, 2026
***********************************************************************************************************************************************************************/
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_PlanCourses = require('../../models/tiers/plan_courses.mdl');
const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl');
const paypal = require('../../services/paypal.service');
const R = require('../../utils/response.util');
require('../../models/tiers/tier.associations');
// ─── Promo codes ──────────────────────────────────────────────────────────────
const PROMO_CODES = {
PHIL10: 10, // $10 flat discount
};
const calculateCheckoutAmount = (price, promoCode) => {
const subtotalCents = Math.round(Number(price) * 100);
const normalizedCode = promoCode?.trim?.().toUpperCase?.() ?? null;
const discountCents = normalizedCode && PROMO_CODES[normalizedCode]
? Math.min(PROMO_CODES[normalizedCode] * 100, subtotalCents)
: 0;
const totalCents = Math.max(subtotalCents - discountCents, 0);
return {
promoCode: discountCents > 0 ? normalizedCode : null,
subtotal: (subtotalCents / 100).toFixed(2),
discount: (discountCents / 100).toFixed(2),
total: (totalCents / 100).toFixed(2),
};
};
// ─── MY TIER ──────────────────────────────────────────────────────────────────
exports.getMyTier = async (req, res) => {
try {
const tier = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Active tier retrieved.', tier ?? { tier: 'free', status: 'active' });
} catch (err) {
console.error('[CLIENT][GET MY TIER]', err);
return R.error(res, 'Could not retrieve tier.', 500);
}
};
exports.getMyTierHistory = async (req, res) => {
try {
const history = await mdl_UserTiers.findAll({
where: { user_id: req.user.user_id },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Tier history retrieved.', history);
} catch (err) {
console.error('[CLIENT][GET MY TIER HISTORY]', err);
return R.error(res, 'Could not retrieve tier history.', 500);
}
};
// ─── PLANS (with courses) ─────────────────────────────────────────────────────
exports.getPlans = async (req, res) => {
try {
const plans = await mdl_TierPlans.findAll({
where: { is_active: true },
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
attributes: ['plan_id', 'tier', 'label', 'duration_days', 'price', 'currency'],
include: [{
model: Course,
as: 'courses',
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
through: { attributes: [] },
}],
});
const result = plans.map((p) => {
const plain = p.toJSON();
plain.course_count = plain.courses?.length ?? 0;
return plain;
});
return R.success(res, 'Plans retrieved.', result);
} catch (err) {
console.error('[CLIENT][GET PLANS]', err);
return R.error(res, 'Could not retrieve plans.', 500);
}
};
// ─── PAYPAL CHECKOUT ──────────────────────────────────────────────────────────
exports.createOrder = async (req, res) => {
try {
const { plan_id, promo_code } = req.body;
if (!plan_id) return R.error(res, 'plan_id is required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
const checkout = calculateCheckoutAmount(plan.price, promo_code);
if (Number(checkout.total) <= 0)
return R.error(res, 'PayPal checkout requires a payable amount.', 400);
const ppOrder = await paypal.createOrder({
amount: checkout.total,
currency: plan.currency,
referenceId: `user_${req.user.user_id}_plan_${plan_id}`,
});
// Extract PayPal approval URL from links array
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
const payment = await mdl_Payments.create({
user_id: req.user.user_id,
plan_id,
status: 'pending',
amount: checkout.total,
currency: plan.currency,
promo_code: checkout.promoCode,
discount: checkout.discount,
provider: 'paypal',
provider_payload: {
order_id: ppOrder.id,
approval_url: approvalUrl,
checkout: {
subtotal: checkout.subtotal,
discount: checkout.discount,
promo_code: checkout.promoCode,
},
},
});
return R.success(res, 'Order created.', {
payment_id: payment.payment_id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: checkout.total,
currency: plan.currency,
promo_code: checkout.promoCode,
discount: checkout.discount,
}, 201);
} catch (err) {
console.error('[CLIENT][CREATE ORDER]', err);
return R.error(res, 'Could not create order.', 500);
}
};
exports.captureOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const payment = await mdl_Payments.findOne({
where: {
status: 'pending',
provider: 'paypal',
user_id: req.user.user_id,
},
include: [{ model: mdl_TierPlans, as: 'plan' }],
order: [['createdAt', 'DESC']],
});
// Match by order_id inside provider_payload
if (!payment || payment.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending payment not found.', 404);
let captureData;
try {
captureData = await paypal.captureOrder(order_id);
} catch (ppErr) {
await payment.update({
status: 'failed',
provider_payload: {
...payment.provider_payload,
error: ppErr?.response?.data ?? {},
},
});
return R.error(res, 'PayPal capture failed.', 402);
}
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// Expire current active tier
await mdl_UserTiers.update(
{ status: 'expired' },
{ where: { user_id: req.user.user_id, status: 'active' } }
);
const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({
user_id: req.user.user_id,
tier: payment.plan.tier,
plan_id: payment.plan_id,
status: 'active',
starts_at: startsAt,
expires_at: expiresAt,
granted_by: null,
});
await payment.update({
status: 'completed',
tier_id: newTier.tier_id,
paid_at: new Date(),
provider_payload: {
...payment.provider_payload,
capture_id: capture?.id,
payer_id: captureData.payer?.payer_id,
capture: captureData,
},
});
// await grantAchievement(req.user.user_id, payment.plan.tier);
await onTierActivated(req.user.user_id, newTier.tier);
return R.success(res, 'Payment successful. Tier activated.', {
tier: newTier.tier,
expires_at: newTier.expires_at,
});
} catch (err) {
console.error('[CLIENT][CAPTURE ORDER]', err);
return R.error(res, 'Could not capture order.', 500);
}
};
exports.cancelOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const payment = await mdl_Payments.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
order: [['createdAt', 'DESC']],
});
if (!payment || payment.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending payment not found.', 404);
await payment.update({
status: 'cancelled',
provider_payload: {
...payment.provider_payload,
cancelled_at: new Date().toISOString(),
cancelled_by: 'payer',
},
});
return R.success(res, 'Payment cancelled.');
} catch (err) {
console.error('[CLIENT][CANCEL ORDER]', err);
return R.error(res, 'Could not cancel payment.', 500);
}
};
// ─── MY PAYMENTS ──────────────────────────────────────────────────────────────
exports.getMyPayments = async (req, res) => {
try {
const payments = await mdl_Payments.findAll({
where: { user_id: req.user.user_id },
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['label', 'tier', 'duration_days'] }],
attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Payment history retrieved.', payments);
} catch (err) {
console.error('[CLIENT][GET MY PAYMENTS]', err);
return R.error(res, 'Could not retrieve payment history.', 500);
}
};
// — add refundOrder export ────────────────
exports.refundOrder = async (req, res) => {
try {
const user_id = req.user.user_id;
// Get the active tier
const activeTier = await mdl_UserTiers.findOne({
where: { user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
if (!activeTier) return R.error(res, 'No active tier to refund.', 404);
// Get the completed payment for this tier
const payment = await mdl_Payments.findOne({
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
order: [['paid_at', 'DESC']],
});
if (!payment) return R.error(res, 'No completed payment found for this tier.', 404);
// Get capture_id from provider_payload
const captureId = payment.provider_payload?.capture_id;
if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400);
// Call PayPal refund API
let refundData;
try {
refundData = await paypal.refundCapture(captureId, payment.amount, payment.currency);
} catch (ppErr) {
console.error('[CLIENT][REFUND] PayPal error:', ppErr?.response?.data);
return R.error(res, 'PayPal refund failed. Please try again.', 402);
}
// Update payment status to refunded
await payment.update({
status: 'refunded',
provider_payload: {
...payment.provider_payload,
refund: refundData,
refunded_at: new Date().toISOString(),
},
});
// Cancel tier at end of period — keep access until expires_at
await activeTier.update({ status: 'revoked' });
return R.success(res, 'Refund processed successfully. Your access will remain until the end of the billing period.', {
refund_id: refundData.id,
status: refundData.status,
expires_at: activeTier.expires_at,
});
} catch (err) {
console.error('[CLIENT][REFUND]', err);
return R.error(res, 'Could not process refund.', 500);
}
};
+40
View File
@@ -0,0 +1,40 @@
/***********************************************************************************************************************************************************************
* File Name: health.controller.js
* Type of Program: Controller
* Description: HTTP layer for health check endpoints. No check logic lives here —
* all checks and payload assembly are handled by services/health.service.js.
*
* GET /api/health → dashboard (rich human-readable, runs all checks)
* GET /api/health/ready → readiness (compact machine-readable, 200 or 503)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 20, 2026
***********************************************************************************************************************************************************************/
"use strict";
const { runDashboard, runReadiness } = require('../services/health.service');
// ─── GET /api/health ─────────────────────────────────────────────────────────
//
// Human-readable dashboard — runs all checks and returns full context:
// app metadata, system info, and per-service connection details.
//
// Used by: developers, monitoring dashboards, manual inspection.
exports.dashboard = async (_req, res) => {
const { httpStatus, body } = await runDashboard();
res.status(httpStatus).json(body);
};
// ─── GET /api/health/ready ───────────────────────────────────────────────────
//
// Machine-readable readiness check — compact response, meaningful HTTP status.
// HTTP 200 → healthy or degraded (safe to route traffic)
// HTTP 503 → unhealthy (critical dependency down; do not route traffic here)
//
// Used by: Kubernetes readiness probe, load balancers, deployment gate scripts.
exports.readiness = async (_req, res) => {
const { httpStatus, body } = await runReadiness();
res.status(httpStatus).json(body);
};
+85
View File
@@ -0,0 +1,85 @@
/***********************************************************************************************************************************************************************
* File Name: media.controller.js (public)
* Type of Program: Controller
* Description: Issues stream tokens for publicly accessible S3 assets — no auth required.
* Guards:
* 1. Asset must exist and not be deleted
* 2. is_public must be true — private assets are always rejected (403)
* 3. storage_provider must be "s3" — Chibisafe uses its raw CDN URL
* Token shape is identical to the client/admin token flows so the
* shared stream endpoint (/api/client/media/stream/:token) accepts it.
* No user_id is embedded — the token is anonymous.
* IP is still bound so a leaked token is useless on another machine.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 22, 2026
***********************************************************************************************************************************************************************/
"use strict";
const jwt = require("jsonwebtoken");
const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl");
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
const TOKEN_TTL_SEC = 4 * 60 * 60; // 4 hours — matches client TTL
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
function resolveIp(req) {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) return forwarded.split(",")[0].trim();
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
}
// ─── GET /public/media/token?asset_id=X ──────────────────────────────────────
exports.issueToken = async (req, res) => {
try {
const asset_id = req.query.asset_id ?? req.body?.asset_id;
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", "is_public"],
});
if (!asset) return R.error(res, "Asset not found.", 404);
// Private assets are never served through the public endpoint
if (!asset.is_public) return R.error(res, "Forbidden.", 403);
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
}
if (asset.storage_provider !== "s3") {
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
}
const ip = resolveIp(req);
const token = jwt.sign(
{
asset_id,
// no user_id — anonymous public token
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip,
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
return R.success(res, "Token issued.", {
token,
provider: "s3",
file_type: asset.file_type,
});
} catch (err) {
console.error("[PUBLIC][MEDIA][TOKEN]", err);
return R.error(res, "Could not issue media token.", 500);
}
};
+8
View File
@@ -17,6 +17,7 @@
const { Op } = require('sequelize');
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at'];
@@ -75,6 +76,13 @@ exports.setUserStatus = async (req, res) => {
if (!user) return R.error(res, 'User not found or operation not permitted.', 404);
await user.update({ is_active });
logActivity(req.user.user_id, 'set_user_status', {
entityType: 'user',
entityId: Number(req.params.id),
metadata: { is_active },
});
return R.success(res, `User ${is_active ? 'activated' : 'deactivated'}.`);
} catch (err) {
return R.error(res, 'Could not update user status.', 500);