mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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.` |
|
||||
@@ -0,0 +1,843 @@
|
||||
# Courses Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/courses.controller.js`
|
||||
**Base URL:** `/api/admin/courses`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Courses](#courses)
|
||||
- [Get All Courses](#get-all-courses)
|
||||
- [Get Single Course](#get-single-course)
|
||||
- [Create Course](#create-course)
|
||||
- [Update Course](#update-course)
|
||||
- [Archive Course](#archive-course)
|
||||
- [Bulk Archive Courses](#bulk-archive-courses)
|
||||
- [Restore Course](#restore-course)
|
||||
- [Bulk Restore Courses](#bulk-restore-courses)
|
||||
- [Get Archived Courses](#get-archived-courses)
|
||||
- [Get Archived Course](#get-archived-course)
|
||||
- [Flat Lists (dropdowns)](#flat-lists)
|
||||
- [Get Course Field Values](#get-course-field-values)
|
||||
- [Course Instructors](#course-instructors)
|
||||
- [Get Instructors](#get-instructors)
|
||||
- [Sync Instructors](#sync-instructors)
|
||||
- [Course Prerequisites](#course-prerequisites)
|
||||
- [Get Prerequisites](#get-prerequisites)
|
||||
- [Sync Prerequisites](#sync-prerequisites)
|
||||
- [Course Assessment](#course-assessment)
|
||||
- [Get Assessment](#get-assessment)
|
||||
- [Create Assessment](#create-assessment)
|
||||
- [Update Assessment](#update-assessment)
|
||||
- [Archive/Restore Assessment](#archiverestore-assessment)
|
||||
- [Quiz Questions](#quiz-questions)
|
||||
- [Get Questions](#get-questions)
|
||||
- [Create Question](#create-question)
|
||||
- [Update Question](#update-question)
|
||||
- [Archive/Restore Question](#archiverestore-question)
|
||||
- [Bulk Archive/Restore Questions](#bulk-archiverestore-questions)
|
||||
- [Units](#units)
|
||||
- [Get All Units](#get-all-units)
|
||||
- [Get Single Unit](#get-single-unit)
|
||||
- [Create Unit](#create-unit)
|
||||
- [Update Unit](#update-unit)
|
||||
- [Archive/Restore Unit](#archiverestore-unit)
|
||||
- [Bulk Archive/Restore Units](#bulk-archiverestore-units)
|
||||
- [Get Unit Field Values](#get-unit-field-values)
|
||||
- [Unit Quiz](#unit-quiz)
|
||||
- [Get Quiz](#get-quiz)
|
||||
- [Create Quiz](#create-quiz)
|
||||
- [Update Quiz](#update-quiz)
|
||||
- [Archive/Restore Quiz](#archiverestore-quiz)
|
||||
- [Lessons](#lessons)
|
||||
- [Get All Lessons](#get-all-lessons)
|
||||
- [Get Single Lesson](#get-single-lesson)
|
||||
- [Create Lesson](#create-lesson)
|
||||
- [Update Lesson](#update-lesson)
|
||||
- [Archive/Restore Lesson](#archiverestore-lesson)
|
||||
- [Bulk Archive/Restore Lessons](#bulk-archiverestore-lessons)
|
||||
- [Get Lesson Field Values](#get-lesson-field-values)
|
||||
- [Lesson Page](#lesson-page)
|
||||
- [Get Lesson Page](#get-lesson-page)
|
||||
- [Upsert Lesson Page](#upsert-lesson-page)
|
||||
- [Course Reading Progress](#course-reading-progress)
|
||||
- [Get Course Reading Progress](#get-course-reading-progress)
|
||||
- [Get User Reading Progress](#get-user-reading-progress)
|
||||
|
||||
---
|
||||
|
||||
## Course Hierarchy
|
||||
|
||||
```
|
||||
Course
|
||||
├── CourseObjective[]
|
||||
├── CoursePrerequisite[]
|
||||
├── CourseAssessment (one)
|
||||
│ └── QuizQuestion[] → QuizOption[]
|
||||
├── CourseInstructor[]
|
||||
└── Unit[]
|
||||
├── UnitQuiz (one)
|
||||
│ └── QuizQuestion[] → QuizOption[]
|
||||
└── Lesson[]
|
||||
├── LessonObjective[]
|
||||
└── LessonPage (one) { blocks: [] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Courses
|
||||
|
||||
### Get All Courses
|
||||
|
||||
**`GET /api/admin/courses`**
|
||||
|
||||
Returns paginated active courses.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
| `filters` | array | No | JSON filter array |
|
||||
| `sort` | array | No | JSON sort array |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Courses retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 12, "totalPages": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Course
|
||||
|
||||
**`GET /api/admin/courses/:courseId`**
|
||||
|
||||
Returns the full course tree: units (with lessons and quiz), objectives, prerequisites, and assessment.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course retrieved.",
|
||||
"data": {
|
||||
"course_id": 1,
|
||||
"uuid": "...",
|
||||
"title": "Advanced JavaScript",
|
||||
"description": "...",
|
||||
"course_code": "JS-201",
|
||||
"order_index": 0,
|
||||
"level": "advanced",
|
||||
"subscription": "premium",
|
||||
"duration_seconds": 7200,
|
||||
"objectives": [{ "objective_id": 1, "text": "Understand closures", "order_index": 0 }],
|
||||
"prerequisites": [],
|
||||
"assessment": { ... },
|
||||
"units": [
|
||||
{
|
||||
"unit_id": 1, "title": "Closures", "order_index": 0,
|
||||
"lessons": [{ "lesson_id": 1, "title": "What is a closure?", "order_index": 0 }],
|
||||
"quiz": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Course
|
||||
|
||||
**`POST /api/admin/courses`**
|
||||
|
||||
Creates a course with optional objectives and category assignments in a single transaction.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | **Yes** | Course title |
|
||||
| `description` | string | No | |
|
||||
| `course_code` | string | No | Unique course identifier |
|
||||
| `order_index` | number | No | Default: `0` |
|
||||
| `level` | string | No | `beginner`, `intermediate`, `advanced` |
|
||||
| `subscription` | string | No | `free`, `premium`. Default: `free` |
|
||||
| `objectives` | array | No | `[{ text, order_index }]` |
|
||||
| `category_ids` | array | No | Category IDs to assign |
|
||||
| `createdBy` | number | No | Creator user ID |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course created.",
|
||||
"data": { "course_id": 5, "title": "Advanced JavaScript", ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Course
|
||||
|
||||
**`PUT /api/admin/courses/:courseId`**
|
||||
|
||||
Updates course fields. When `objectives` or `category_ids` are provided, they replace the existing sets.
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `description`, `order_index`, `course_code`, `level`, `subscription`, `objectives`, `category_ids`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive Course
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId`**
|
||||
|
||||
Soft-deletes the course.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Course archived." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Courses
|
||||
|
||||
**`DELETE /api/admin/courses/bulk`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2, 3] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Restore Course
|
||||
|
||||
**`PATCH /api/admin/courses/:courseId/restore`**
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Courses
|
||||
|
||||
**`PATCH /api/admin/courses/restore/bulk`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Courses
|
||||
|
||||
**`GET /api/admin/courses/archives`**
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Course
|
||||
|
||||
**`GET /api/admin/courses/archives/:courseId`**
|
||||
|
||||
---
|
||||
|
||||
### Flat Lists
|
||||
|
||||
Lightweight endpoints that return `uuid + title` arrays (no pagination). Used by the task requirement builder dropdowns.
|
||||
|
||||
**`GET /api/admin/courses/flat`** — all active courses: `[{ uuid, title }]`
|
||||
|
||||
**`GET /api/admin/courses/units-flat`** — all active units: `[{ uuid, title, order_index, course_title }]`
|
||||
|
||||
**`GET /api/admin/courses/lessons-flat`** — all active lessons: `[{ uuid, title, order_index, unit_title, unit_order, course_title }]`
|
||||
|
||||
---
|
||||
|
||||
### Get Course Field Values
|
||||
|
||||
**`GET /api/admin/courses/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Course Instructors
|
||||
|
||||
### Get Instructors
|
||||
|
||||
**`GET /api/admin/courses/:courseId/instructors`**
|
||||
|
||||
Returns instructors ordered by `order_index`. Includes linked `users` (staff/admin accounts) when `user_id` is set.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Instructors retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"course_id": 5,
|
||||
"user_id": 3,
|
||||
"display_name": "Dr. Jane Smith",
|
||||
"order_index": 0,
|
||||
"user": { "user_id": 3, "email": "jane@example.com", "acc_type": "staff", "personal_info": { ... } }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync Instructors
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/instructors`**
|
||||
|
||||
Replaces the full instructor list for a course in a single transaction.
|
||||
|
||||
- Validates any `user_id` values — they must be `staff` or `admin` accounts.
|
||||
- Passing an empty array clears all instructors.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"instructors": [
|
||||
{ "user_id": 3, "display_name": "Dr. Jane Smith", "order_index": 0 },
|
||||
{ "user_id": null, "display_name": "External Contributor", "order_index": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Course Prerequisites
|
||||
|
||||
### Get Prerequisites
|
||||
|
||||
**`GET /api/admin/courses/:courseId/prerequisites`**
|
||||
|
||||
Returns prerequisites ordered by `order_index`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Prerequisites retrieved.",
|
||||
"data": [
|
||||
{ "prereq_id": 1, "course_id": 5, "ref_type": "course", "ref_id": 2, "order_index": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync Prerequisites
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/prerequisites`**
|
||||
|
||||
Replaces the full prerequisite list. Valid `ref_type` values: `course`, `unit`, `lesson`.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"prerequisites": [
|
||||
{ "ref_type": "course", "ref_id": 2 },
|
||||
{ "ref_type": "unit", "ref_id": 7 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Course Assessment
|
||||
|
||||
One assessment per course. Assessment questions are shared with unit quizzes via polymorphic `assessment_id` / `quiz_id` fields.
|
||||
|
||||
### Get Assessment
|
||||
|
||||
**`GET /api/admin/courses/:courseId/assessment`**
|
||||
|
||||
Returns the assessment with its questions and options.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Assessment retrieved.",
|
||||
"data": {
|
||||
"assessment_id": 1,
|
||||
"uuid": "...",
|
||||
"course_id": 5,
|
||||
"title": "Final Exam",
|
||||
"is_required": true,
|
||||
"passing_score": 80,
|
||||
"time_limit_minutes": 60,
|
||||
"max_questions": 20,
|
||||
"questions": [
|
||||
{
|
||||
"question_id": 1, "type": "multiple_choice", "question": "What is a closure?",
|
||||
"explanation": "...", "points": 2, "order_index": 0,
|
||||
"options": [
|
||||
{ "option_id": 1, "text": "A function + its outer scope", "is_correct": true, "order_index": 0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Assessment
|
||||
|
||||
**`POST /api/admin/courses/:courseId/assessment`**
|
||||
|
||||
Only one assessment per course. Returns `409` if one already exists.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | No | |
|
||||
| `is_required` | boolean | No | Default: `false` |
|
||||
| `passing_score` | number | No | Default: `70` |
|
||||
| `time_limit_minutes` | number | No | `null` = no limit |
|
||||
| `max_questions` | number | No | `null` = show all |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Assessment
|
||||
|
||||
**`PATCH /api/admin/courses/:courseId/assessment/:assessmentId`**
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Assessment
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/assessment/:assessmentId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/assessment/:assessmentId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/assessment/archives`** — get archived assessment
|
||||
|
||||
---
|
||||
|
||||
## Quiz Questions
|
||||
|
||||
Shared by both **Unit Quizzes** and **Course Assessments**. The parent is determined by the route:
|
||||
|
||||
- Under a unit quiz: `/:courseId/units/:unitId/quiz/:quizId/questions`
|
||||
- Under an assessment: `/:courseId/assessment/:assessmentId/questions`
|
||||
|
||||
### Question Types
|
||||
|
||||
| Type | Options Required |
|
||||
|------|----------------|
|
||||
| `true_false` | Auto-generated `[True, False]` if `options` is empty |
|
||||
| `multiple_choice` | Exactly one `is_correct: true` option |
|
||||
| `multi_select` | One or more `is_correct: true` options |
|
||||
|
||||
### Get Questions
|
||||
|
||||
**`GET .../:parentId/questions`**
|
||||
|
||||
Returns questions with options, ordered by `order_index`.
|
||||
|
||||
---
|
||||
|
||||
### Create Question
|
||||
|
||||
**`POST .../:parentId/questions`**
|
||||
|
||||
Creates a question and its options in a transaction. `true_false` questions auto-generate `[True, False]` options if `options` is omitted.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `type` | string | **Yes** | `true_false`, `multiple_choice`, `multi_select` |
|
||||
| `question` | string | **Yes** | Question text |
|
||||
| `explanation` | string | No | Shown after answer |
|
||||
| `order_index` | number | No | Default: `0` |
|
||||
| `points` | number | No | Default: `1` |
|
||||
| `options` | array | No | `[{ text, is_correct, order_index }]` |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Question created.",
|
||||
"data": { "question_id": 1, "type": "multiple_choice", "options": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Question
|
||||
|
||||
**`PATCH .../:parentId/questions/:questionId`**
|
||||
|
||||
When `options` is provided, the full option set is replaced (destroy + re-insert).
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Question
|
||||
|
||||
**`DELETE .../:parentId/questions/:questionId`** — archive
|
||||
**`PATCH .../:parentId/questions/:questionId/restore`** — restore
|
||||
**`GET .../:parentId/questions/archives/:questionId`** — get archived question
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive/Restore Questions
|
||||
|
||||
**`DELETE .../:parentId/questions/bulk`** — bulk archive
|
||||
**`PATCH .../:parentId/questions/restore/bulk`** — bulk restore
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2, 3], "deletedBy": 1 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Units
|
||||
|
||||
### Get All Units
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units`**
|
||||
|
||||
Returns paginated active units for a course, ordered by `order_index`.
|
||||
|
||||
---
|
||||
|
||||
### Get Single Unit
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId`**
|
||||
|
||||
Returns a unit with its lessons and quiz.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Unit retrieved.",
|
||||
"data": {
|
||||
"unit_id": 1, "uuid": "...", "course_id": 5,
|
||||
"title": "Introduction", "description": "...",
|
||||
"order_index": 0, "duration_seconds": 1800,
|
||||
"lessons": [...],
|
||||
"quiz": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Unit
|
||||
|
||||
**`POST /api/admin/courses/:courseId/units`**
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | **Yes** | |
|
||||
| `description` | string | No | |
|
||||
| `order` | number | No | Default: `0` |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Unit
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/units/:unitId`**
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `description`, `order`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Unit
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/units/archives`** — list archived
|
||||
**`GET /api/admin/courses/:courseId/units/archives/:unitId`** — get one archived
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive/Restore Units
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/bulk`** — bulk archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/restore/bulk`** — bulk restore
|
||||
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Unit Field Values
|
||||
|
||||
**`GET /api/admin/courses/:courseId/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Unit Quiz
|
||||
|
||||
One quiz per unit. Shares `QuizQuestion` / `QuizOption` with course assessments (via `quiz_id` FK).
|
||||
|
||||
### Get Quiz
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/quiz`**
|
||||
|
||||
Returns the quiz with questions and options.
|
||||
|
||||
---
|
||||
|
||||
### Create Quiz
|
||||
|
||||
**`POST /api/admin/courses/:courseId/units/:unitId/quiz`**
|
||||
|
||||
Only one quiz per unit. Returns `409` if one already exists.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | No | |
|
||||
| `is_required` | boolean | No | Default: `false` |
|
||||
| `passing_score` | number | No | Default: `70` |
|
||||
| `max_questions` | number | No | `null` = show all |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Quiz
|
||||
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId`**
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `is_required`, `passing_score`, `max_questions`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Quiz
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId/quiz/:quizId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/quiz/archives`** — get archived quiz
|
||||
|
||||
---
|
||||
|
||||
## Lessons
|
||||
|
||||
### Get All Lessons
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons`**
|
||||
|
||||
Returns paginated active lessons, ordered by `order_index`.
|
||||
|
||||
---
|
||||
|
||||
### Get Single Lesson
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`**
|
||||
|
||||
Returns a lesson with its page (blocks) and objectives.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Lesson retrieved.",
|
||||
"data": {
|
||||
"lesson_id": 1, "uuid": "...", "unit_id": 1,
|
||||
"title": "What is a closure?", "description": "...",
|
||||
"duration_seconds": 900, "order_index": 0,
|
||||
"page": { "page_id": 1, "lesson_id": 1, "blocks": [...] },
|
||||
"objectives": [{ "objective_id": 1, "text": "Understand closures", "order_index": 0 }],
|
||||
"unit": { "unit_id": 1, "course_id": 5, ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Lesson
|
||||
|
||||
**`POST /api/admin/courses/:courseId/units/:unitId/lessons`**
|
||||
|
||||
Creates a lesson, an empty `LessonPage` (blocks: `[]`), and optional objectives in a single transaction.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | **Yes** | |
|
||||
| `description` | string | No | |
|
||||
| `order` | number | No | Default: `0` |
|
||||
| `objectives` | array | No | `[{ text, order_index }]` |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Lesson
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`**
|
||||
|
||||
When `objectives` is provided, the full set is replaced.
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `description`, `order`, `objectives`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Lesson
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/archives`** — list archived
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/archives/:lessonId`** — get one archived
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive/Restore Lessons
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId/lessons/bulk`** — bulk archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/lessons/restore/bulk`** — bulk restore
|
||||
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Lesson Field Values
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Lesson Page
|
||||
|
||||
Each lesson has exactly **one** page. A page is created automatically when a lesson is created (with empty `blocks`).
|
||||
|
||||
### Get Lesson Page
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page`**
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Lesson page retrieved.",
|
||||
"data": {
|
||||
"page_id": 1,
|
||||
"lesson_id": 3,
|
||||
"blocks": [
|
||||
{ "type": "text", "content": "A closure is..." },
|
||||
{ "type": "video", "asset_id": 12, "duration_seconds": 300 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Upsert Lesson Page
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page`**
|
||||
|
||||
Creates or replaces the lesson page's block content. After saving, `duration_seconds` on the lesson (and its ancestor unit and course) is automatically recomputed from video blocks.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `blocks` | array | **Yes** | Block array |
|
||||
| `updatedBy` | number | No | |
|
||||
|
||||
### Response `200` (updated) / `201` (created)
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Lesson page updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `blocks must be an array.` |
|
||||
| `404` | `Lesson not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Course Reading Progress
|
||||
|
||||
> These endpoints are served from `controllers/admin/course_reading_progress.controller.js` but mounted on the `/api/admin/courses` router.
|
||||
|
||||
### Get Course Reading Progress
|
||||
|
||||
**`GET /api/admin/courses/:courseId/reading-progress`**
|
||||
|
||||
Returns one summary row per user who has touched the course. Includes lesson/unit completion counts derived from the live course structure (not from stored counters).
|
||||
|
||||
Sorted: in-progress users first (most recent access first), completed users last.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course reading progress retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"user_id": 42,
|
||||
"course_status": "in_progress",
|
||||
"last_accessed_at": "2026-06-21T09:00:00.000Z",
|
||||
"lessons_completed": 3,
|
||||
"units_completed": 1,
|
||||
"user": {
|
||||
"email": "user@example.com",
|
||||
"full_name": "Jane Doe",
|
||||
"avatar_url": "https://..."
|
||||
},
|
||||
"units_total": 4,
|
||||
"lessons_total": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get User Reading Progress
|
||||
|
||||
**`GET /api/admin/courses/:courseId/reading-progress/users/:userId`**
|
||||
|
||||
Loaded lazily when the admin expands a user row. Returns the full unit → lesson breakdown with progress status per item.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User reading progress retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"unit_id": 1, "uuid": "...", "title": "Introduction",
|
||||
"status": "completed",
|
||||
"lessons": [
|
||||
{
|
||||
"lesson_id": 1, "uuid": "...", "title": "What is a closure?",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-06-20T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"lesson_id": 2, "uuid": "...", "title": "Closure examples",
|
||||
"status": null,
|
||||
"completed_at": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
# Dashboard Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/dashboard.controller.js`
|
||||
**Base URL:** `/api/admin/dashboard`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Users Dashboard](#users-dashboard)
|
||||
- [Groups Dashboard](#groups-dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Users Dashboard
|
||||
|
||||
**`GET /api/admin/dashboard/users`**
|
||||
|
||||
Returns summary stats and breakdowns for all users. All counts run in parallel.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users dashboard data fetched.",
|
||||
"data": {
|
||||
"stats": [
|
||||
{ "key": "total", "label": "Total Users", "value": 100 },
|
||||
{ "key": "active", "label": "Active Users", "value": 85 },
|
||||
{ "key": "inactive", "label": "Inactive Users", "value": 10 },
|
||||
{ "key": "verified", "label": "Verified", "value": 80 },
|
||||
{ "key": "archived", "label": "Archived", "value": 5 }
|
||||
],
|
||||
"breakdowns": [
|
||||
{
|
||||
"key": "acc_type",
|
||||
"label": "By Account Type",
|
||||
"data": [
|
||||
{ "label": "user", "value": 90 },
|
||||
{ "label": "staff", "value": 8 },
|
||||
{ "label": "admin", "value": 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "reg_type",
|
||||
"label": "By Registration Type",
|
||||
"data": [
|
||||
{ "label": "system", "value": 75 },
|
||||
{ "label": "google", "value": 25 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Groups Dashboard
|
||||
|
||||
**`GET /api/admin/dashboard/groups`**
|
||||
|
||||
Returns summary stats and a top-10 groups-by-member-count breakdown.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Groups dashboard data fetched.",
|
||||
"data": {
|
||||
"stats": [
|
||||
{ "key": "total", "label": "Total Groups", "value": 20 },
|
||||
{ "key": "active", "label": "Active Groups", "value": 18 },
|
||||
{ "key": "inactive", "label": "Inactive Groups", "value": 0 },
|
||||
{ "key": "archived", "label": "Archived", "value": 2 },
|
||||
{ "key": "empty", "label": "Empty Groups", "value": 3 }
|
||||
],
|
||||
"breakdowns": [
|
||||
{
|
||||
"key": "top_groups",
|
||||
"label": "Top Groups by Members",
|
||||
"data": [
|
||||
{ "label": "Engineering", "value": 42 },
|
||||
{ "label": "Marketing", "value": 30 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,188 @@
|
||||
# Landing Pages / Page Builder — Schema Documentation
|
||||
|
||||
**Migrations:** `41b`, `42`, `43`, `44`, `45`, `49`
|
||||
**Base URL (planned):** `/api/admin/pages`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
> **TODO:** Controller, service, and routes for this module have not been created yet.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Overview](#overview)
|
||||
- [Schema](#schema)
|
||||
- [pages](#pages)
|
||||
- [landing_pages](#landing_pages)
|
||||
- [page_sections](#page_sections)
|
||||
- [page_blocks](#page_blocks)
|
||||
- [page_templates](#page_templates)
|
||||
- [page_user_groups](#page_user_groups)
|
||||
- [Relationships](#relationships)
|
||||
- [Design Decisions](#design-decisions)
|
||||
- [ENUM Types](#enum-types)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The page builder uses a generalized `pages` root table as the single owner of all `page_sections`. This means sections and blocks are not locked to `landing_pages` — any future page type (`lesson_page`, `course_page`, etc.) can reuse the same section/block system by linking to `pages`.
|
||||
|
||||
**Hierarchy:**
|
||||
```
|
||||
pages
|
||||
├── landing_pages (1:1 via page_id)
|
||||
└── page_sections (1:N via page_id)
|
||||
└── page_blocks (1:N via page_section_id)
|
||||
|
||||
page_templates — standalone reusable layout snapshots
|
||||
page_user_groups — controls which groups can see a page
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
### `pages`
|
||||
|
||||
Root identity table. Every page type gets a row here first.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------|------------|----------------------|------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| type | page_type | NOT NULL | Discriminator for the page type |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_pages_type` on `(type)`
|
||||
|
||||
---
|
||||
|
||||
### `landing_pages`
|
||||
|
||||
Landing-page-specific metadata. One-to-one with `pages`.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------------|--------------------|------------------------------------|------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| page_id | BIGINT | NOT NULL, UNIQUE, FK → pages(id) | Link to root pages table |
|
||||
| title | VARCHAR(255) | NOT NULL | |
|
||||
| slug | VARCHAR(255) | NOT NULL, UNIQUE | URL path segment |
|
||||
| meta_title | VARCHAR(255) | | SEO title override |
|
||||
| meta_description | TEXT | | SEO description |
|
||||
| status | landing_page_status | NOT NULL DEFAULT 'draft' | |
|
||||
| published_at | TIMESTAMPTZ | | Set when first published |
|
||||
| created_by | BIGINT | FK → users(user_id) SET NULL | Admin who created the page |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_landing_pages_status` on `(status)`
|
||||
|
||||
---
|
||||
|
||||
### `page_sections`
|
||||
|
||||
Ordered sections within any page. References `pages`, not `landing_pages`.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------|------------------|--------------------------------|------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| page_id | BIGINT | NOT NULL, FK → pages(id) | Belongs to a page (any type) |
|
||||
| type | page_section_type | NOT NULL | Section layout type |
|
||||
| label | VARCHAR(255) | | Admin-facing label |
|
||||
| position | INT | NOT NULL DEFAULT 0 | Display order |
|
||||
| settings | JSONB | | Background, padding, layout, etc. |
|
||||
| is_visible | BOOLEAN | NOT NULL DEFAULT true | Toggle section visibility |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_page_sections_page_id` on `(page_id)`, `idx_page_sections_order` on `(page_id, position)`
|
||||
|
||||
---
|
||||
|
||||
### `page_blocks`
|
||||
|
||||
Content blocks within a section.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|-----------------|----------------|------------------------------------|----------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| page_section_id | BIGINT | NOT NULL, FK → page_sections(id) | |
|
||||
| type | page_block_type | NOT NULL | Block content type |
|
||||
| content | JSONB | | Payload — varies by type (see below) |
|
||||
| position | INT | NOT NULL DEFAULT 0 | Display order within section |
|
||||
| settings | JSONB | | Block-level style overrides |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_page_blocks_section_id` on `(page_section_id)`, `idx_page_blocks_order` on `(page_section_id, position)`
|
||||
|
||||
**`content` shape by block type:**
|
||||
| type | content fields |
|
||||
|--------|-----------------------------------------|
|
||||
| text | `{ body: string }` |
|
||||
| image | `{ src: string, alt: string }` |
|
||||
| button | `{ label: string, href: string, variant: string }` |
|
||||
| video | `{ src: string, autoplay: boolean }` |
|
||||
| form | `{ form_id: number }` |
|
||||
| spacer | `{ height: number }` |
|
||||
|
||||
---
|
||||
|
||||
### `page_templates`
|
||||
|
||||
Reusable layout snapshots. Stored as a full JSONB dump of sections + blocks — not live FK references.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---------------|-------------|------------------------|--------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| name | VARCHAR(255) | NOT NULL | Template display name |
|
||||
| thumbnail_url | TEXT | | Preview image URL |
|
||||
| structure | JSONB | | Full snapshot of sections and blocks |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
---
|
||||
|
||||
### `page_user_groups`
|
||||
|
||||
Junction table — controls which user groups can access a page.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------|-------------|--------------------------------------|-------------|
|
||||
| page_id | BIGINT | PK, FK → pages(id) ON DELETE CASCADE | |
|
||||
| group_id | BIGINT | PK, FK → user_groups(group_id) | |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_page_user_groups_group_id` on `(group_id)`
|
||||
**Primary Key:** composite `(page_id, group_id)`
|
||||
|
||||
---
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
pages 1 ──── 1 landing_pages
|
||||
pages 1 ──── N page_sections
|
||||
pages N ──── N user_groups (via page_user_groups)
|
||||
page_sections 1 ──── N page_blocks
|
||||
```
|
||||
|
||||
Cascade deletes flow top-down: deleting a `pages` row removes its `landing_pages` record, all its `page_sections`, and all nested `page_blocks` automatically.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **`pages` as root** — sections belong to `pages`, not directly to `landing_pages`. Adding a new page type (e.g. `course_page`) only requires a new detail table + adding its value to the `page_type` ENUM. No changes to `page_sections` or `page_blocks`.
|
||||
- **`page_templates.structure` is a snapshot** — templates store a JSONB copy of sections/blocks, not live FK references. This keeps templates stable when source pages are edited.
|
||||
- **`page_sections.page_id`** points to `pages(id)` directly, giving sections access to any page type without schema changes.
|
||||
|
||||
---
|
||||
|
||||
## ENUM Types
|
||||
|
||||
| Type | Values |
|
||||
|---------------------|------------------------------------------------------------------------|
|
||||
| `page_type` | `landing_page`, `lesson_page`, `course_page` |
|
||||
| `landing_page_status` | `draft`, `published`, `archived` |
|
||||
| `page_section_type` | `hero`, `features`, `cta`, `testimonials`, `faq`, `pricing`, `gallery`, `custom` |
|
||||
| `page_block_type` | `text`, `image`, `button`, `video`, `form`, `spacer` |
|
||||
@@ -0,0 +1,125 @@
|
||||
# Notifications Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/notification.controller.js`
|
||||
**Base URL:** `/api/admin/notifications`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Notifications](#get-all-notifications)
|
||||
- [Get Unseen Count](#get-unseen-count)
|
||||
- [Mark One as Seen](#mark-one-as-seen)
|
||||
- [Mark All as Seen](#mark-all-as-seen)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `admin_notifications` are **system-wide** — not scoped to a user. All admins see the same feed.
|
||||
- Records are never deleted — seen/unseen state is toggled only.
|
||||
- Notifications are created internally (e.g., task overdue events) — no POST endpoint is exposed.
|
||||
|
||||
---
|
||||
|
||||
## Get All Notifications
|
||||
|
||||
**`GET /api/admin/notifications`**
|
||||
|
||||
Returns paginated notifications, newest first.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `20`, max: `50` |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Notifications fetched.",
|
||||
"data": {
|
||||
"notifications": [
|
||||
{
|
||||
"notification_id": 1,
|
||||
"type": "task_overdue",
|
||||
"title": "Tasks Overdue",
|
||||
"message": "5 tasks are now overdue.",
|
||||
"data": { "count": 5 },
|
||||
"seen": false,
|
||||
"seen_at": null,
|
||||
"createdAt": "2026-06-19T10:00:00.000Z",
|
||||
"updatedAt": "2026-06-19T10:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 10,
|
||||
"pages": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Unseen Count
|
||||
|
||||
**`GET /api/admin/notifications/unseen`**
|
||||
|
||||
Returns a count of unseen notifications. Used for the bell badge.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Unseen count fetched.",
|
||||
"data": { "count": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mark One as Seen
|
||||
|
||||
**`PATCH /api/admin/notifications/:id/seen`**
|
||||
|
||||
Marks a single notification as seen and sets `seen_at` to the current timestamp.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Notification marked as seen.",
|
||||
"data": {
|
||||
"notification_id": 1,
|
||||
"seen": true,
|
||||
"seen_at": "2026-06-21T10:00:00.000Z",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Notification not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Mark All as Seen
|
||||
|
||||
**`PATCH /api/admin/notifications/seen-all`**
|
||||
|
||||
Marks all unseen notifications as seen in a single update.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "7 notification(s) marked as seen.",
|
||||
"data": { "count": 7 }
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Products Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/products.controller.js`
|
||||
**Base URL:** `/api/admin/products`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get Course Product](#get-course-product)
|
||||
- [Upsert Course Product](#upsert-course-product)
|
||||
- [Remove Course Product](#remove-course-product)
|
||||
- [Get Course Categories](#get-course-categories)
|
||||
- [Sync Course Categories](#sync-course-categories)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Each course has **at most one** product listing (`products` table, unique on `course_id`).
|
||||
- Upsert restores a soft-deleted product if one exists rather than creating a duplicate.
|
||||
- Categories are managed via the `course_product_categories` junction table (many-to-many between `courses` and `categories`).
|
||||
- Syncing categories replaces the full set — it is a replace-all, not an append.
|
||||
|
||||
---
|
||||
|
||||
## Get Course Product
|
||||
|
||||
**`GET /api/admin/products/courses/:courseId/product`**
|
||||
|
||||
Returns the product listing for a course, or `null` if none exists. Includes archived products (`paranoid: false`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Product retrieved.",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"course_id": 5,
|
||||
"name": "Advanced JavaScript",
|
||||
"description": "Full course access",
|
||||
"price": "49.99",
|
||||
"currency": "USD",
|
||||
"access_days": 365,
|
||||
"is_active": true,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upsert Course Product
|
||||
|
||||
**`PUT /api/admin/products/courses/:courseId/product`**
|
||||
|
||||
Creates the product if it does not exist. If a soft-deleted product exists, it is restored and updated. If an active product exists, it is updated.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Product name |
|
||||
| `price` | number | **Yes** | Price (decimal, e.g. `49.99`) |
|
||||
| `description` | string | No | |
|
||||
| `currency` | string | No | ISO 4217 code. Default: `USD` |
|
||||
| `access_days` | number | No | Days of access after purchase. `null` = lifetime |
|
||||
| `is_active` | boolean | No | Default: `true` |
|
||||
|
||||
### Response `200` (updated) / `201` (created)
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Product updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `name and price are required.` |
|
||||
|
||||
---
|
||||
|
||||
## Remove Course Product
|
||||
|
||||
**`DELETE /api/admin/products/courses/:courseId/product`**
|
||||
|
||||
Soft-deletes the course product.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Product removed." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Product not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Get Course Categories
|
||||
|
||||
**`GET /api/admin/products/courses/:courseId/categories`**
|
||||
|
||||
Returns the list of categories assigned to a course.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course categories retrieved.",
|
||||
"data": [
|
||||
{ "id": 1, "name": "Business", "slug": "business", "is_active": true },
|
||||
{ "id": 3, "name": "Design", "slug": "design", "is_active": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Course not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Sync Course Categories
|
||||
|
||||
**`POST /api/admin/products/courses/:courseId/categories`**
|
||||
|
||||
Replaces the full set of category assignments for a course. All existing assignments are removed first, then the new set is inserted.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "category_ids": [1, 3, 7] }
|
||||
```
|
||||
|
||||
Pass an empty array to clear all category assignments.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Course categories updated." }
|
||||
```
|
||||
@@ -0,0 +1,148 @@
|
||||
# Profile Controller Documentation (Admin)
|
||||
|
||||
**File:** `controllers/admin/profile.controller.js`
|
||||
**Base URL:** `/api/admin/profile`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get Profile](#get-profile)
|
||||
- [Update Profile](#update-profile)
|
||||
- [Upload Avatar](#upload-avatar)
|
||||
- [Delete Avatar](#delete-avatar)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All endpoints are self-service — they operate on the **currently authenticated admin** (`req.user.user_id`).
|
||||
- `password`, `otp_code`, and `otp_expires_at` are always excluded from responses.
|
||||
- Avatar files are stored in S3 via `s3.service`. The old avatar is deleted before upload.
|
||||
|
||||
---
|
||||
|
||||
## Get Profile
|
||||
|
||||
**`GET /api/admin/profile`**
|
||||
|
||||
Returns the authenticated admin's full user record.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Profile retrieved.",
|
||||
"data": {
|
||||
"user_id": 1,
|
||||
"email": "admin@example.com",
|
||||
"is_active": true,
|
||||
"is_verified": true,
|
||||
"reg_type": "system",
|
||||
"acc_type": "admin",
|
||||
"personal_info": {
|
||||
"name": {
|
||||
"given_name": "Kenneth",
|
||||
"middle_name": null,
|
||||
"last_name": "Obsequio",
|
||||
"extension_name": null,
|
||||
"full_name": "Kenneth Obsequio"
|
||||
},
|
||||
"occupation": null,
|
||||
"addresses": [],
|
||||
"phone_number": [],
|
||||
"date_of_birth": null,
|
||||
"avatar": {
|
||||
"url": "https://...",
|
||||
"uuid": "storage-key",
|
||||
"name": "avatar.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"size": 204800
|
||||
}
|
||||
},
|
||||
"createdAt": "2025-10-06T00:00:00.000Z",
|
||||
"updatedAt": "2026-06-18T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Profile
|
||||
|
||||
**`PUT /api/admin/profile`**
|
||||
|
||||
Deep-merges `personal_info` — top-level keys and `name` sub-keys are merged separately. Existing keys not present in the request body are preserved.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"personal_info": {
|
||||
"name": {
|
||||
"given_name": "Kenneth",
|
||||
"last_name": "Obsequio"
|
||||
},
|
||||
"occupation": "Software Engineer",
|
||||
"date_of_birth": "1995-05-15"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Profile updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upload Avatar
|
||||
|
||||
**`POST /api/admin/profile/avatar`**
|
||||
|
||||
Uploads (or replaces) the admin's avatar via `multipart/form-data`.
|
||||
|
||||
- Old avatar is deleted from S3 before uploading the new one.
|
||||
- Avatar metadata is stored in `personal_info.avatar`.
|
||||
|
||||
### Request
|
||||
`Content-Type: multipart/form-data`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `file` | file | **Yes** | Image file (handled by `avatar_upload.middleware`) |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Avatar updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `No file provided.` |
|
||||
|
||||
---
|
||||
|
||||
## Delete Avatar
|
||||
|
||||
**`DELETE /api/admin/profile/avatar`**
|
||||
|
||||
Removes the admin's avatar from S3 and sets `personal_info.avatar` to `null`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Avatar removed." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `No avatar to remove.` |
|
||||
@@ -0,0 +1,563 @@
|
||||
# Tasks Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/task.controller.js` + `controllers/admin/task_completion.controller.js`
|
||||
**Base URL:** `/api/admin/task-lists`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Task Lists](#task-lists)
|
||||
- [Get All Task Lists](#get-all-task-lists)
|
||||
- [Get Single Task List](#get-single-task-list)
|
||||
- [Create Task List](#create-task-list)
|
||||
- [Update Task List](#update-task-list)
|
||||
- [Archive Task List](#archive-task-list)
|
||||
- [Restore Task List](#restore-task-list)
|
||||
- [Bulk Archive Task Lists](#bulk-archive-task-lists)
|
||||
- [Bulk Restore Task Lists](#bulk-restore-task-lists)
|
||||
- [Get Archived Task Lists](#get-archived-task-lists)
|
||||
- [Get Task List Field Values](#get-task-list-field-values)
|
||||
- [Task List Groups](#task-list-groups)
|
||||
- [Get Assigned Groups](#get-assigned-groups)
|
||||
- [Assign Groups](#assign-groups)
|
||||
- [Unassign Groups](#unassign-groups)
|
||||
- [Tasks](#tasks)
|
||||
- [Get All Tasks](#get-all-tasks)
|
||||
- [Get Single Task](#get-single-task)
|
||||
- [Create Task](#create-task)
|
||||
- [Update Task](#update-task)
|
||||
- [Archive Task](#archive-task)
|
||||
- [Restore Task](#restore-task)
|
||||
- [Bulk Archive Tasks](#bulk-archive-tasks)
|
||||
- [Bulk Restore Tasks](#bulk-restore-tasks)
|
||||
- [Get Archived Tasks](#get-archived-tasks)
|
||||
- [Get Task Field Values](#get-task-field-values)
|
||||
- [Completions](#completions)
|
||||
- [Get All Completions](#get-all-completions)
|
||||
- [Get Single Completion](#get-single-completion)
|
||||
- [Get Completions by User](#get-completions-by-user)
|
||||
- [Archive Completion](#archive-completion)
|
||||
- [Restore Completion](#restore-completion)
|
||||
- [Bulk Archive Completions](#bulk-archive-completions)
|
||||
- [Bulk Restore Completions](#bulk-restore-completions)
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
```
|
||||
TaskList ──< Task ──< TaskRequirement
|
||||
│
|
||||
└──< TaskListGroup >── UserGroup
|
||||
```
|
||||
|
||||
- A **TaskList** is a named container of tasks, visible to assigned user groups.
|
||||
- A **Task** belongs to one TaskList and has 0-N requirements.
|
||||
- A **TaskRequirement** specifies what a user must do (visit a link, upload a file, or read a course/unit/lesson).
|
||||
- Completions are submitted by **clients only** — admins can view and archive/restore them.
|
||||
|
||||
---
|
||||
|
||||
## Task Requirement Types
|
||||
|
||||
| `type` | Required Fields | Description |
|
||||
|--------|----------------|-------------|
|
||||
| `visit_link` | `link_url`, `link_label` | User must visit a URL |
|
||||
| `upload_file` | `allowed_file_types`, `max_file_count` | User must upload files |
|
||||
| `read_course` | `reference_id` (course UUID), `reference_label` | User must complete a course |
|
||||
| `read_unit` | `reference_id` (unit UUID), `reference_label` | User must complete a unit |
|
||||
| `read_lesson` | `reference_id` (lesson UUID), `reference_label` | User must complete a lesson |
|
||||
|
||||
---
|
||||
|
||||
## Task Lists
|
||||
|
||||
### Get All Task Lists
|
||||
|
||||
**`GET /api/admin/task-lists`**
|
||||
|
||||
Returns paginated active task lists.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
| `filters` | array | No | JSON filter array |
|
||||
| `sort` | array | No | JSON sort array |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task lists retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Task List
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId`**
|
||||
|
||||
Returns a task list with its full task tree (tasks → requirements) and assigned groups. Includes archived items (`paranoid: false`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task list retrieved.",
|
||||
"data": {
|
||||
"task_list_id": "uuid-...",
|
||||
"name": "Onboarding Q1",
|
||||
"description": "...",
|
||||
"group_count": 2,
|
||||
"groups": [
|
||||
{ "group_id": 1, "name": "Engineering" }
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "uuid-...",
|
||||
"name": "Read the handbook",
|
||||
"deadline": "2026-07-01T00:00:00.000Z",
|
||||
"status": "pending",
|
||||
"requirements": [
|
||||
{ "requirement_id": "uuid-...", "type": "read_lesson", "reference_id": "uuid-..." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"createdAt": "...",
|
||||
"updatedAt": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Task List
|
||||
|
||||
**`POST /api/admin/task-lists`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Unique list name |
|
||||
| `description` | string | No | |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task list created successfully.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Task List
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | No | |
|
||||
| `description` | string | No | |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Task list updated successfully.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Archive Task List
|
||||
|
||||
**`DELETE /api/admin/task-lists/:taskListId`**
|
||||
|
||||
Rate-limited. Soft-deletes the task list.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Task list archived successfully." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Restore Task List
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Task list restored successfully.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Task Lists
|
||||
|
||||
**`POST /api/admin/task-lists/bulk-archive`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": ["uuid-1", "uuid-2"] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 task list(s) archived successfully.",
|
||||
"archived_ids": [...],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Task Lists
|
||||
|
||||
**`POST /api/admin/task-lists/bulk-restore`**
|
||||
|
||||
Rate-limited. Same body shape as bulk archive.
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Task Lists
|
||||
|
||||
**`GET /api/admin/task-lists/archived`**
|
||||
|
||||
Returns paginated soft-deleted task lists.
|
||||
|
||||
---
|
||||
|
||||
### Get Task List Field Values
|
||||
|
||||
**`GET /api/admin/task-lists/field-values`**
|
||||
|
||||
Returns distinct filterable field values for task lists.
|
||||
|
||||
---
|
||||
|
||||
## Task List Groups
|
||||
|
||||
### Get Assigned Groups
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/groups`**
|
||||
|
||||
Returns all user groups currently assigned to the task list, including assignment metadata.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task list groups retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid-...",
|
||||
"task_list_id": "uuid-...",
|
||||
"group_id": 1,
|
||||
"assignedAt": "2026-06-01T00:00:00.000Z",
|
||||
"assignedBy": 1,
|
||||
"group": { "group_id": 1, "name": "Engineering" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Assign Groups
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/groups/assign`**
|
||||
|
||||
Rate-limited. Upsert-style — already-assigned groups are silently skipped.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "group_ids": [1, 2, 3] }
|
||||
```
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 group(s) assigned.",
|
||||
"assigned_ids": [2, 3],
|
||||
"already_assigned_ids": [1],
|
||||
"invalid_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Unassign Groups
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/groups/unassign`**
|
||||
|
||||
Rate-limited. Hard-deletes the junction rows (assignments are not soft-deleted).
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "group_ids": [2, 3] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 group(s) unassigned.",
|
||||
"unassigned_ids": [2, 3],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Get All Tasks
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks`**
|
||||
|
||||
Returns paginated tasks under a task list.
|
||||
|
||||
### Query Parameters
|
||||
Standard pagination + `filters` + `sort`.
|
||||
|
||||
---
|
||||
|
||||
### Get Single Task
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId`**
|
||||
|
||||
Returns a task with its requirements and the parent task list (including assigned groups).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task retrieved.",
|
||||
"data": {
|
||||
"task_id": "uuid-...",
|
||||
"task_list_id": "uuid-...",
|
||||
"name": "Complete orientation",
|
||||
"description": "...",
|
||||
"deadline": "2026-07-01T00:00:00.000Z",
|
||||
"status": "pending",
|
||||
"requirements": [
|
||||
{
|
||||
"requirement_id": "uuid-...",
|
||||
"type": "upload_file",
|
||||
"allowed_file_types": ["application/pdf"],
|
||||
"max_file_count": 1,
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"taskList": { "task_list_id": "...", "name": "Onboarding Q1", "groups": [...] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Task
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks`**
|
||||
|
||||
Rate-limited. Creates a task and optionally its requirements in a single transaction.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Task name |
|
||||
| `description` | string | No | |
|
||||
| `deadline` | date | No | ISO date string |
|
||||
| `requirements` | array | No | Array of requirement objects (see Requirement Types above) |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task created successfully.",
|
||||
"data": { "task_id": "uuid-...", "name": "...", "requirements": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Task
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId`**
|
||||
|
||||
Rate-limited. When `requirements` is provided, the full set is replaced (soft-delete + re-insert).
|
||||
|
||||
> **Note:** Incoming requirement objects must **not** include `requirement_id` — the server always generates fresh IDs to avoid collisions with soft-deleted rows.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | No | |
|
||||
| `description` | string | No | |
|
||||
| `deadline` | date | No | |
|
||||
| `status` | string | No | `pending`, `in_progress`, `completed`, `overdue` |
|
||||
| `requirements` | array | No | Full replacement set |
|
||||
|
||||
---
|
||||
|
||||
### Archive Task
|
||||
|
||||
**`DELETE /api/admin/task-lists/:taskListId/tasks/:taskId`**
|
||||
|
||||
Rate-limited. Soft-deletes the task.
|
||||
|
||||
---
|
||||
|
||||
### Restore Task
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Tasks
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/bulk-archive`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": ["uuid-1", "uuid-2"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Tasks
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/bulk-restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Tasks
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/archived`**
|
||||
|
||||
---
|
||||
|
||||
### Get Task Field Values
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Completions
|
||||
|
||||
Completions are created by clients only. Admins can view and archive/restore them.
|
||||
|
||||
Each completion may have multiple attached files (`task_completion_files`).
|
||||
|
||||
---
|
||||
|
||||
### Get All Completions
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions`**
|
||||
|
||||
Returns paginated completions for a task, with submitting user info and files.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Completions retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"completion_id": "uuid-...",
|
||||
"task_id": "uuid-...",
|
||||
"user_id": 42,
|
||||
"note": "Attached the signed form.",
|
||||
"submitted_at": "2026-06-15T10:00:00.000Z",
|
||||
"user": { "user_id": 42, "email": "user@example.com", "name": "Jane Doe" },
|
||||
"files": [
|
||||
{
|
||||
"file_id": "uuid-...",
|
||||
"file_url": "https://...",
|
||||
"file_name": "signed_form.pdf",
|
||||
"file_size": 204800,
|
||||
"mime_type": "application/pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"pagination": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Completion
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId`**
|
||||
|
||||
Returns a single completion with user and files.
|
||||
|
||||
---
|
||||
|
||||
### Get Completions by User
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId`**
|
||||
|
||||
Returns all completions for a specific user on a specific task.
|
||||
|
||||
---
|
||||
|
||||
### Archive Completion
|
||||
|
||||
**`DELETE /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId`**
|
||||
|
||||
Rate-limited. Soft-deletes a completion.
|
||||
|
||||
---
|
||||
|
||||
### Restore Completion
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Completions
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": ["uuid-1", "uuid-2"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Completions
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore`**
|
||||
|
||||
Rate-limited.
|
||||
@@ -0,0 +1,406 @@
|
||||
# Tiers Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/tiers.controller.js`
|
||||
**Base URL:** `/api/admin/tiers`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Tier Plans](#tier-plans)
|
||||
- [Get All Plans](#get-all-plans)
|
||||
- [Get Single Plan](#get-single-plan)
|
||||
- [Create Plan](#create-plan)
|
||||
- [Update Plan](#update-plan)
|
||||
- [Archive Plan](#archive-plan)
|
||||
- [Bulk Archive Plans](#bulk-archive-plans)
|
||||
- [Restore Plan](#restore-plan)
|
||||
- [Bulk Restore Plans](#bulk-restore-plans)
|
||||
- [Get Plan Field Values](#get-plan-field-values)
|
||||
- [Plan Courses](#plan-courses)
|
||||
- [Get Plan Courses](#get-plan-courses)
|
||||
- [Sync Plan Courses](#sync-plan-courses)
|
||||
- [User Tiers](#user-tiers)
|
||||
- [Get User Tiers](#get-user-tiers)
|
||||
- [Grant Tier](#grant-tier)
|
||||
- [Revoke Tier](#revoke-tier)
|
||||
- [Payments](#payments)
|
||||
- [Get All Payments](#get-all-payments)
|
||||
- [Get Single Payment](#get-single-payment)
|
||||
- [Get Payment Field Values](#get-payment-field-values)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Pending payments older than **60 minutes** are automatically expired before any payment list/detail call.
|
||||
- Revoking a tier immediately creates a new `free` tier row for the user (auto-downgrade).
|
||||
- `plan_id` is serialized as a string in create responses to avoid BigInt overflow in JS.
|
||||
- A plan's `tier` field cannot be updated — only `label`, `duration_days`, `price`, `currency`, and `is_active`.
|
||||
|
||||
---
|
||||
|
||||
## Tier Plans
|
||||
|
||||
### Get All Plans
|
||||
|
||||
**`GET /api/admin/tiers`**
|
||||
|
||||
Returns paginated plans. Pass `?archived=true` to see soft-deleted plans instead.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|---------|----------|-------------|
|
||||
| `archived` | boolean | No | `true` to show archived plans only |
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plans retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 4, "totalPages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Plan
|
||||
|
||||
**`GET /api/admin/tiers/:id`**
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plan retrieved.",
|
||||
"data": {
|
||||
"plan_id": 1,
|
||||
"tier": "premium",
|
||||
"label": "Premium Monthly",
|
||||
"duration_days": 30,
|
||||
"price": "9.99",
|
||||
"currency": "USD",
|
||||
"is_active": true,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Plan
|
||||
|
||||
**`POST /api/admin/tiers`**
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `tier` | string | **Yes** | `premium` or `exclusive` |
|
||||
| `label` | string | **Yes** | Human-readable plan name |
|
||||
| `duration_days` | number | **Yes** | Access duration in days |
|
||||
| `price` | number | **Yes** | Plan price (decimal) |
|
||||
| `currency` | string | No | ISO 4217. Default: `USD` |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plan created.",
|
||||
"data": { "plan_id": "5", "tier": "premium", ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Plan
|
||||
|
||||
**`PUT /api/admin/tiers/:id`**
|
||||
|
||||
Only `label`, `duration_days`, `price`, `currency`, and `is_active` are updatable.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan updated.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Archive Plan
|
||||
|
||||
**`DELETE /api/admin/tiers/:id`**
|
||||
|
||||
Sets `is_active = false` then soft-deletes.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan archived successfully." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Plans
|
||||
|
||||
**`POST /api/admin/tiers/bulk/archive`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 plan(s) archived successfully.",
|
||||
"archived_ids": [1, 2],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Restore Plan
|
||||
|
||||
**`POST /api/admin/tiers/:id/restore`**
|
||||
|
||||
Restores plan and sets `is_active = true`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan restored successfully." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Plans
|
||||
|
||||
**`POST /api/admin/tiers/bulk/restore`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 plan(s) restored successfully.",
|
||||
"restored_ids": [1, 2],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Plan Field Values
|
||||
|
||||
**`GET /api/admin/tiers/field-values`**
|
||||
|
||||
Returns distinct filterable field values for tier plans.
|
||||
|
||||
---
|
||||
|
||||
## Plan Courses
|
||||
|
||||
### Get Plan Courses
|
||||
|
||||
**`GET /api/admin/tiers/:id/courses`**
|
||||
|
||||
Returns the list of courses linked to this plan.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plan courses retrieved.",
|
||||
"data": [
|
||||
{ "course_id": 1, "title": "Intro to Python", "course_code": "PY-101", "subscription": "premium", "level": "beginner" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync Plan Courses
|
||||
|
||||
**`POST /api/admin/tiers/:id/courses`**
|
||||
|
||||
Replaces the full set of courses for this plan. Removes all existing assignments first, then inserts the new set. A course can only belong to one plan at a time — existing assignments to other plans are cleared automatically.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "course_ids": [1, 2, 3] }
|
||||
```
|
||||
|
||||
Pass `[]` to remove all courses from the plan.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan courses updated." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Tiers
|
||||
|
||||
### Get User Tiers
|
||||
|
||||
**`GET /api/admin/tiers/users/:id/tiers`**
|
||||
|
||||
Returns the full tier history for a user, newest first. Includes `grantedByUser` and `revokedByUser` info.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User tiers retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"tier_id": 3,
|
||||
"user_id": 42,
|
||||
"tier": "premium",
|
||||
"status": "active",
|
||||
"starts_at": "2026-06-01T00:00:00.000Z",
|
||||
"expires_at": "2026-07-01T00:00:00.000Z",
|
||||
"granted_by": 1,
|
||||
"revoked_by": null,
|
||||
"revoked_at": null,
|
||||
"notes": "Trial promotion",
|
||||
"grantedByUser": { "user_id": 1, "email": "admin@example.com" },
|
||||
"revokedByUser": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Grant Tier
|
||||
|
||||
**`POST /api/admin/tiers/users/tiers/grant`**
|
||||
|
||||
- Expires all currently active tiers for the user before creating the new one.
|
||||
- `expires_at` is calculated as `now + plan.duration_days`.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `user_id` | number | **Yes** | Target user |
|
||||
| `tier` | string | **Yes** | `premium` or `exclusive` |
|
||||
| `plan_id` | number | **Yes** | Must match the plan's tier |
|
||||
| `notes` | string | No | Optional admin note |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Tier granted.",
|
||||
"data": { "tier_id": 4, "user_id": 42, "tier": "premium", "status": "active", ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `user_id, tier, and plan_id are required.` |
|
||||
| `400` | `Plan tier mismatch.` |
|
||||
| `404` | `User not found.` |
|
||||
| `404` | `Plan not found or inactive.` |
|
||||
|
||||
---
|
||||
|
||||
### Revoke Tier
|
||||
|
||||
**`PATCH /api/admin/tiers/users/tiers/:tid/revoke`**
|
||||
|
||||
- Sets the tier's status to `revoked` and records `revoked_by` / `revoked_at`.
|
||||
- Automatically creates a new `free` tier row for the user (auto-downgrade note: `"Auto-downgrade after revoke."`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Tier revoked. User downgraded to free." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `Tier is not active.` |
|
||||
| `404` | `Tier record not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Payments
|
||||
|
||||
### Get All Payments
|
||||
|
||||
**`GET /api/admin/tiers/payments`**
|
||||
|
||||
Returns paginated payment records. Stale pending payments (>60 min old) are expired before the query runs.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
| `filters` | array | No | JSON filter array |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Payments retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"payment_id": 1,
|
||||
"user_id": 42,
|
||||
"plan_id": 1,
|
||||
"status": "completed",
|
||||
"amount": "9.99",
|
||||
"currency": "USD",
|
||||
"promo_code": null,
|
||||
"discount": "0.00",
|
||||
"provider": "paypal",
|
||||
"paid_at": "2026-06-01T10:00:00.000Z",
|
||||
"user": { "user_id": 42, "email": "user@example.com" },
|
||||
"plan": { "plan_id": 1, "label": "Premium Monthly", "tier": "premium", "duration_days": 30 }
|
||||
}
|
||||
],
|
||||
"pagination": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Payment
|
||||
|
||||
**`GET /api/admin/tiers/payments/:id`**
|
||||
|
||||
Returns full payment detail including `user`, `plan`, and `tier` associations.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Payment retrieved.",
|
||||
"data": {
|
||||
"payment_id": 1,
|
||||
"provider_payload": { "order_id": "...", "capture_id": "...", ... },
|
||||
"user": { ... },
|
||||
"plan": { ... },
|
||||
"tier": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Payment Field Values
|
||||
|
||||
**`GET /api/admin/tiers/payments/field-values`**
|
||||
|
||||
Returns distinct filterable field values for payments. `provider_payload` is excluded.
|
||||
@@ -0,0 +1,129 @@
|
||||
# User Activity Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/user_activity.controller.js`
|
||||
**Base URL:** `/api/admin/activity` and `/api/admin/users/:id/activity`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get Global Activity Feed](#get-global-activity-feed)
|
||||
- [Get Per-User Activity](#get-per-user-activity)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `user_activity` rows are **never soft-deleted** — this is an append-only audit log.
|
||||
- `created_at` is the timestamp column (no `createdAt` alias — Sequelize `timestamps: false`).
|
||||
- The global feed endpoint joins the `users` table and enriches each row with `full_name` and `avatar_url`.
|
||||
- The per-user endpoint skips the join for performance since user context is already known.
|
||||
|
||||
---
|
||||
|
||||
## Activity Object Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": 1,
|
||||
"user_id": 42,
|
||||
"email": "user@example.com",
|
||||
"full_name": "Kenneth Obsequio",
|
||||
"avatar_url": "https://...",
|
||||
"acc_type": "admin",
|
||||
"action": "login",
|
||||
"entity_type": "session",
|
||||
"entity_id": 7,
|
||||
"details": { "session_id": 7, "reg_type": "system" },
|
||||
"created_at": "2026-06-21T08:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Common `action` Values
|
||||
| Action | Entity Type | Details Keys |
|
||||
|--------|-------------|--------------|
|
||||
| `login` | `session` | `session_id`, `reg_type` |
|
||||
| `deactivate_user` | `user` | `target_email` |
|
||||
| `lesson_read` | `lesson` | `lesson_uuid`, `status` |
|
||||
| `submit_task` | `task` | `task_id` |
|
||||
| `set_user_status` | `user` | `is_active` |
|
||||
| `create_course` | `course` | `title` |
|
||||
| `update_course` | `course` | `title` |
|
||||
| `archive_course` | `course` | — |
|
||||
| `create_task_list` | `task_list` | `name` |
|
||||
| `create_advertisement` | `advertisement` | `type` |
|
||||
| `grant_tier` | `tier` | `user_id`, `tier`, `plan_id` |
|
||||
| `revoke_tier` | `tier` | `user_id`, `tier` |
|
||||
|
||||
---
|
||||
|
||||
## Get Global Activity Feed
|
||||
|
||||
**`GET /api/admin/activity`**
|
||||
|
||||
Returns a paginated, reverse-chronological feed of all user activity across the system.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `20`, max: `100` |
|
||||
| `action` | string | No | Filter by exact action string (e.g. `login`) |
|
||||
| `from` | date | No | ISO date — `created_at >= from` |
|
||||
| `to` | date | No | ISO date — `created_at <= to` |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Activity feed retrieved.",
|
||||
"data": {
|
||||
"total": 500,
|
||||
"page": 1,
|
||||
"totalPages": 25,
|
||||
"activities": [ ... ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Per-User Activity
|
||||
|
||||
**`GET /api/admin/users/:id/activity`**
|
||||
|
||||
Returns a paginated, reverse-chronological activity log for a single user.
|
||||
|
||||
### Query Parameters
|
||||
Same as global feed (`page`, `limit`, `action`, `from`, `to`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User activity retrieved.",
|
||||
"data": {
|
||||
"total": 45,
|
||||
"page": 1,
|
||||
"totalPages": 3,
|
||||
"activities": [
|
||||
{
|
||||
"activity_id": 1,
|
||||
"user_id": 42,
|
||||
"session_id": null,
|
||||
"action": "update_course",
|
||||
"entity_type": "course",
|
||||
"entity_id": 3,
|
||||
"details": { "title": "Advanced JS" },
|
||||
"created_at": "2026-06-21T09:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `Invalid User ID.` |
|
||||
| `404` | `User not found.` |
|
||||
@@ -0,0 +1,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 };
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user