Files
starr-philproperties/controllers/admin/advertisements.controller.js
T
kennethobsequio 439bb33f77 ready to test
Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-22 10:06:58 +08:00

357 lines
16 KiB
JavaScript

// 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");