// controllers/admin/advertisements.controller.js const sequelize = require("../../config/db.config"); const Advertisement = require("../../models/advertisements/advertisements.mdl"); const mdl_Assets = require("../../models/assets/assets.mdl"); const mdl_Users = require('../../models/users/users.mdl'); const mediaToken = require("../../services/mediaToken.service"); const R = require('../../utils/response.util'); const { paginate } = require("../../utils/paginate.util"); const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes"); const { PLACEMENT_MAP, PLACEMENT_KEYS } = require("../../models/advertisements/advertisements.placements"); const { getFieldValues } = require("../../utils/fieldValues.util"); const logActivity = require('../../utils/logActivity.util'); const { Op } = require('sequelize'); // ─── Helpers ────────────────────────────────────────────────────────────────── const notDeleted = { deletedAt: null }; const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"]; // Fields needed off the associated Asset to render a preview AND (for S3 assets) // mint a stream token — storage_key is stripped again in attachImageStreamToken // before the row is ever sent out. const AD_IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"]; // ─── Media proxying ───────────────────────────────────────────────────────── // Mirrors controllers/admin/assets.controller.js's redactS3Url/attachStreamTokens. // Private (S3-backed) advertisement images must never expose a raw file_url to // the browser — mint a short-lived stream token instead so the frontend resolves // it through GET /api/client/media/stream/:token. Public/chibisafe images keep // their direct file_url (no proxy needed). async function attachImageStreamToken(image, req) { if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) { return image; } const ip = mediaToken.resolveIp(req); const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip); image.stream_token = token; image.file_url = null; image.thumbnail_url = null; delete image.storage_key; return image; } // ─── Status derivation ───────────────────────────────────────────────────── // status is never trusted as manually-set truth — it's derived from is_active // + start_date/end_date every time an advertisement is read or written. // "archived" is the only status that bypasses derivation (set by archive/restore). function deriveStatus(advertisement) { if (advertisement.deletedAt) return "archived"; if (!advertisement.is_active) return "draft"; const now = new Date(); const start = advertisement.start_date ? new Date(advertisement.start_date) : null; const end = advertisement.end_date ? new Date(advertisement.end_date) : null; if (end && end < now) return "expired"; if (start && start > now) return "scheduled"; return "active"; } function normalizeCtas(ctas) { if (!Array.isArray(ctas)) return []; return ctas .filter((c) => c && typeof c.label === "string" && typeof c.link === "string") .slice(0, 2) // hard cap: max 2 CTAs per advertisement .map((c, i) => ({ label: c.label.trim(), link: c.link.trim(), // Variant is always derived from position — first CTA is "default" // (primary), second is "outline" — not user-selectable, so any // client-sent variant is ignored. variant: i === 0 ? "default" : "outline", })); } // Hard cap: max 2 badge labels per advertisement (matches MAX_BADGE_LABELS on the frontend) function normalizeBadgeLabels(labels) { if (!Array.isArray(labels)) return []; return labels .filter((l) => typeof l === "string" && l.trim().length > 0) .map((l) => l.trim()) .slice(0, 2); } async function applyAdvertisementFields(advertisement, body) { // placement is the only settable "where" — type/format is always derived // from the placement's registry entry, never accepted directly from the body. if (body.placement !== undefined) { const entry = PLACEMENT_MAP[body.placement]; if (!entry) { const err = new Error(`Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`); err.status = 400; throw err; } advertisement.placement = body.placement; advertisement.type = entry.format; } // status is intentionally NOT settable here — it's derived via deriveStatus() // right before save, based on is_active + start_date/end_date. // content_mode is no longer an admin choice (the Image Only / Text with // Image toggle was removed — every ad now carries the same mandatory // badge/headline/description/image/link shape) — like type/format, it's // fixed server-side rather than trusted from the request body. Legacy // "image" mode rows keep that value until next edited. advertisement.content_mode = "content"; if (body.badge_labels !== undefined) advertisement.badge_labels = normalizeBadgeLabels(body.badge_labels); if (body.headline !== undefined) advertisement.headline = body.headline; if (body.description !== undefined) advertisement.description = body.description; if (body.image_url !== undefined) advertisement.image_url = body.image_url; if (body.redirect_link !== undefined) advertisement.redirect_link = body.redirect_link || null; if (body.landing_page !== undefined) advertisement.landing_page = body.landing_page || null; if (body.image_asset_id !== undefined) { if (body.image_asset_id === null) { advertisement.image_asset_id = null; } else { const asset = await mdl_Assets.findOne({ where: { asset_id: body.image_asset_id, deletedAt: null } }); if (!asset) { const err = new Error("Selected image 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", "xl"].includes(body.size)) { const err = new Error(`Invalid size. Must be one of: sm, md, lg, xl`); err.status = 400; throw err; } advertisement.size = body.size || null; } // Every ad now carries the same mandatory shape — badge label(s), headline, // description, image, and a single link — enforced here as defense-in-depth // alongside the frontend's Zod schema. The Admin Add/Edit Advertisement // forms always submit the full shape, so this only ever fires on malformed // requests — it does not retroactively touch existing incomplete rows, // it just blocks saving one until it's brought up to the new shape. if (!advertisement.badge_labels?.length) { const err = new Error("At least one badge label is required."); err.status = 400; throw err; } if (!advertisement.headline?.trim()) { const err = new Error("Headline is required."); err.status = 400; throw err; } if (!advertisement.description?.trim()) { const err = new Error("Description is required."); err.status = 400; throw err; } if (!advertisement.image_asset_id) { const err = new Error("Image is required."); err.status = 400; throw err; } if (!advertisement.redirect_link?.trim()) { const err = new Error("Link is required."); err.status = 400; throw err; } // Recompute status now that is_active/start_date/end_date are all up to date advertisement.status = deriveStatus(advertisement); } // ─── GET ALL ────────────────────────────────────────────────────────────────── // Keeps the stored `status` column in sync with deriveStatus() before the // filtered query runs — status is otherwise only recomputed on individual // row reads, so filtering by status (e.g. "expired") would miss rows whose // start_date/end_date lapsed since they were last saved. async function syncDerivedStatuses() { await sequelize.query(` UPDATE advertisements SET status = CASE WHEN is_active = false THEN 'draft' WHEN end_date IS NOT NULL AND end_date < NOW() THEN 'expired' WHEN start_date IS NOT NULL AND start_date > NOW() THEN 'scheduled' ELSE 'active' END WHERE "deletedAt" IS NULL `); } exports.getAdvertisements = async (req, res) => { try { await syncDerivedStatuses(); const result = await paginate(Advertisement, req, { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, context: "list", auditOptions: { mdl_Users, parentAlias: 'Advertisement' }, findOptions: { where: { ...notDeleted }, include: [{ model: mdl_Assets, as: "image", attributes: AD_IMAGE_ATTRIBUTES, required: false, }], }, }); // Resync status on the way out — never trust what's stored, since // start_date/end_date may have lapsed since the row was last saved. if (Array.isArray(result?.data)) { result.data = await Promise.all(result.data.map(async (row) => { if (row.image) await attachImageStreamToken(row.image, req); return { ...row, status: deriveStatus(row) }; })); } return R.success(res, "Advertisements retrieved.", result); } catch (err) { console.error("[ADVERTISEMENT][GET ALL]", err); return R.error(res, "Could not retrieve advertisements.", 500); } }; // ─── GET ONE ────────────────────────────────────────────────────────────────── exports.getAdvertisement = async (req, res) => { try { const { advertisementId } = req.params; if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400); const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted }, include: [ { model: mdl_Assets, as: "image", attributes: AD_IMAGE_ATTRIBUTES, required: false }, { model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" }, { model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" }, ], }); if (!advertisement) return R.error(res, "Advertisement not found.", 404); const json = advertisement.toJSON(); json.status = deriveStatus(json); if (json.image) await attachImageStreamToken(json.image, req); if (json.creator) { json.creator = { user_id: json.creator.user_id, full_name: json.creator.personal_info?.name?.full_name ?? null, }; } if (json.updater) { json.updater = { user_id: json.updater.user_id, full_name: json.updater.personal_info?.name?.full_name ?? null, }; } return R.success(res, "Advertisement retrieved.", { data: json }); } catch (err) { console.error("[ADVERTISEMENT][GET ONE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── CREATE ─────────────────────────────────────────────────────────────────── exports.createAdvertisement = async (req, res) => { try { const { placement, createdBy } = req.body; if (!placement) return R.error(res, "placement is required.", 400); const entry = PLACEMENT_MAP[placement]; if (!entry) return R.error(res, `Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`, 400); if (!createdBy) return R.error(res, "createdBy is required.", 400); const t = await sequelize.transaction(); try { const advertisement = await Advertisement.build({ placement, type: entry.format, createdBy }); await applyAdvertisementFields(advertisement, req.body); // No manual "order" input in the UI anymore — new ads always append to // the end of their placement's priority list rather than colliding at 0. if (req.body.order === undefined) { advertisement.order = await Advertisement.count({ where: { placement, ...notDeleted }, transaction: t }); } await advertisement.save({ transaction: t }); await t.commit(); logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { placement: advertisement.placement, type: advertisement.type } }); return R.success(res, "Advertisement created.", { data: advertisement }, 201); } catch (dbErr) { try { await t.rollback(); } catch { /* connection gone */ } throw dbErr; } } catch (err) { console.error("[ADVERTISEMENT][CREATE]", err); if (err.status) return R.error(res, err.message, err.status); return R.error(res, "Internal server error.", 500); } }; // ─── UPDATE ─────────────────────────────────────────────────────────────────── exports.updateAdvertisement = async (req, res) => { try { const { advertisementId } = req.params; if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400); const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } }); if (!advertisement) return R.error(res, "Advertisement not found.", 404); const t = await sequelize.transaction(); try { await applyAdvertisementFields(advertisement, req.body); advertisement.updatedBy = req.body.updatedBy ?? null; await advertisement.save({ transaction: t }); await t.commit(); logActivity(req.user?.user_id, 'update_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) }); return R.success(res, "Advertisement updated.", { data: advertisement }); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } throw dbErr; } } catch (err) { console.error("[ADVERTISEMENT][UPDATE]", err); if (err.status) return R.error(res, err.message, err.status); return R.error(res, "Internal server error.", 500); } }; // ─── REORDER ────────────────────────────────────────────────────────────────── // `order` is scoped per placement (mirrors controllers/client/advertisements.controller.js's // getActiveAdvertisement[List], which picks the lowest `order` within a placement to show // first) — Move Up/Down swaps position among siblings sharing the same placement, then // re-sequences the whole group to 0..n-1. Re-sequencing (not just swapping the two `order` // values) is what makes this self-healing against legacy ties, since every ad defaulted to // order: 0 before this feature existed — a plain swap between two tied rows would no-op. exports.reorderAdvertisement = async (req, res) => { try { const { advertisementId } = req.params; const { direction } = req.body; if (!["up", "down"].includes(direction)) return R.error(res, "direction must be 'up' or 'down'.", 400); const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } }); if (!advertisement) return R.error(res, "Advertisement not found.", 404); const group = await Advertisement.findAll({ where: { placement: advertisement.placement, ...notDeleted }, order: [["order", "ASC"], ["createdAt", "DESC"]], }); const index = group.findIndex((a) => a.advertisement_id === advertisement.advertisement_id); const swapWith = direction === "up" ? index - 1 : index + 1; if (swapWith < 0 || swapWith >= group.length) { return R.error(res, `This ad is already at the ${direction === "up" ? "top" : "bottom"} of its placement.`, 400); } [group[index], group[swapWith]] = [group[swapWith], group[index]]; const t = await sequelize.transaction(); try { await Promise.all(group.map((a, i) => a.update({ order: i }, { transaction: t }))); await t.commit(); } catch (dbErr) { try { await t.rollback(); } catch { /* connection gone */ } throw dbErr; } logActivity(req.user?.user_id, 'reorder_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId), details: { placement: advertisement.placement, direction }, }); return R.success(res, "Order updated.", { data: { updates: group.map((a) => ({ advertisement_id: a.advertisement_id, order: a.order })) }, }); } catch (err) { console.error("[ADVERTISEMENT][REORDER]", err); return R.error(res, "Internal server error.", 500); } }; // ─── ARCHIVE (single) ───────────────────────────────────────────────────────── exports.archiveAdvertisement = async (req, res) => { try { const { advertisementId } = req.params; if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400); const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } }); if (!advertisement) return R.error(res, "Advertisement not found.", 404); // Freeze the derived status (e.g. "expired") onto the row before it goes // paranoid — the archived list trusts this stored value as-is and never // re-derives it, so "Remove Expired" would otherwise miss ads that had // already lapsed at the moment an admin manually archived them. await advertisement.update({ deletedBy: req.body.deletedBy ?? null, status: deriveStatus(advertisement) }); await advertisement.destroy(); logActivity(req.user?.user_id, 'archive_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) }); return R.success(res, "Advertisement archived."); } catch (err) { console.error("[ADVERTISEMENT][ARCHIVE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── ARCHIVE (bulk) ─────────────────────────────────────────────────────────── exports.archiveAdvertisements = async (req, res) => { try { const { ids, deletedBy } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids }, ...notDeleted } }); if (!advertisements.length) return R.error(res, "No advertisements found.", 404); const activeIds = advertisements.map((a) => a.advertisement_id); // Same status-freeze as the single-archive path — resync each row's // status right before it goes paranoid so "Remove Expired" can trust it. await Promise.all(advertisements.map((a) => a.update({ deletedBy: deletedBy ?? null, status: deriveStatus(a) }) )); await Advertisement.destroy({ where: { advertisement_id: { [Op.in]: activeIds } } }); logActivity(req.user?.user_id, 'bulk_archive_advertisements', { entityType: 'advertisement', details: { ids: activeIds, count: activeIds.length } }); return R.success(res, `${activeIds.length} advertisement(s) archived.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { console.error("[ADVERTISEMENT][BULK ARCHIVE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── RESTORE (single) ───────────────────────────────────────────────────────── exports.restoreAdvertisement = async (req, res) => { try { const { advertisementId } = req.params; const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId }, paranoid: false }); if (!advertisement) return R.error(res, "Advertisement not found.", 404); if (!advertisement.deletedAt) return R.error(res, "Advertisement is not archived.", 400); await advertisement.restore(); await advertisement.update({ deletedBy: null }); logActivity(req.user?.user_id, 'restore_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) }); return R.success(res, "Advertisement restored.", { data: advertisement }); } catch (err) { console.error("[ADVERTISEMENT][RESTORE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── RESTORE (bulk) ─────────────────────────────────────────────────────────── exports.restoreAdvertisements = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids } }, paranoid: false }); if (!advertisements.length) return R.error(res, "No advertisements found.", 404); const archived = advertisements.filter((a) => a.deletedAt); if (!archived.length) return R.error(res, "All selected advertisements are already active.", 400); const archivedIds = archived.map((a) => a.advertisement_id); await Advertisement.restore({ where: { advertisement_id: { [Op.in]: archivedIds } } }); await Advertisement.update({ deletedBy: null }, { where: { advertisement_id: { [Op.in]: archivedIds } }, paranoid: false }); logActivity(req.user?.user_id, 'bulk_restore_advertisements', { entityType: 'advertisement', details: { ids: archivedIds, count: archivedIds.length } }); return R.success(res, `${archivedIds.length} advertisement(s) restored.`, { restored_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), }); } catch (err) { console.error("[ADVERTISEMENT][BULK RESTORE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── ARCHIVED LIST ──────────────────────────────────────────────────────────── exports.getArchivedAdvertisements = async (req, res) => { try { const result = await paginate(Advertisement, req, { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, context: "archived", auditOptions: { mdl_Users, parentAlias: 'Advertisement' }, findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }, }); return R.success(res, "Archived advertisements retrieved.", result); } catch (err) { console.error("[ADVERTISEMENT][GET ARCHIVED]", err); return R.error(res, "Could not retrieve archived advertisements.", 500); } }; exports.getAdvertisementFieldValues = getFieldValues(Advertisement, "ADVERTISEMENT"); // ─── PERMANENT DELETE (single) ──────────────────────────────────────────────── exports.permanentlyDeleteAdvertisement = async (req, res) => { try { const { advertisementId } = req.params; const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId }, paranoid: false }); if (!advertisement) return R.error(res, "Advertisement not found.", 404); if (!advertisement.deletedAt) return R.error(res, "Advertisement must be archived before it can be permanently deleted.", 400); await advertisement.destroy({ force: true }); logActivity(req.user?.user_id, 'permanently_delete_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) }); return R.success(res, "Advertisement permanently deleted."); } catch (err) { console.error("[ADVERTISEMENT][PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete advertisement.", 500); } }; // ─── PERMANENT DELETE (bulk) ─────────────────────────────────────────────────── exports.permanentlyDeleteAdvertisements = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids } }, paranoid: false }); if (!advertisements.length) return R.error(res, "No advertisements found.", 404); const archived = advertisements.filter((a) => a.deletedAt); if (!archived.length) return R.error(res, "All selected advertisements must be archived before they can be permanently deleted.", 400); const archivedIds = archived.map((a) => a.advertisement_id); await Advertisement.destroy({ where: { advertisement_id: { [Op.in]: archivedIds } }, force: true }); logActivity(req.user?.user_id, 'bulk_permanently_delete_advertisements', { entityType: 'advertisement', details: { ids: archivedIds, count: archivedIds.length } }); return R.success(res, `${archivedIds.length} advertisement(s) permanently deleted.`, { deleted_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), }); } catch (err) { console.error("[ADVERTISEMENT][BULK PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete advertisements.", 500); } };