// controllers/client/advertisements.controller.js const Advertisement = require("../../models/advertisements/advertisements.mdl"); const mdl_Assets = require("../../models/assets/assets.mdl"); const mediaToken = require("../../services/mediaToken.service"); const R = require('../../utils/response.util'); const { PLACEMENT_MAP } = require("../../models/advertisements/advertisements.placements"); const { Op } = require('sequelize'); const AD_IMAGE_INCLUDE = { model: mdl_Assets, as: "image", attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"], required: false, }; const AD_CLIENT_EXCLUDE = ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"]; // ─── Media proxying ───────────────────────────────────────────────────────── // Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken. // Kept duplicated rather than shared to avoid a cross-boundary import between // admin and client controllers (same rationale as deriveStatus above). Private // (S3-backed) images never expose a raw file_url — the frontend resolves the // stream_token through GET /api/client/media/stream/:token instead. 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 ───────────────────────────────────────────────────── // 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"; } // ─── Live window helper ───────────────────────────────────────────────────── // "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. function liveWhere(extra) { const now = new Date(); return { ...extra, 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 } }] }, ], }; } // ─── GET ACTIVE ─────────────────────────────────────────────────────────────── // // Resolves the single highest-priority live advertisement for a given placement. // // GET /api/client/advertisements/active?placement=dashboard.hero // exports.getActiveAdvertisement = async (req, res) => { try { const { placement } = req.query; if (!placement) return R.error(res, "placement is required.", 400); if (!PLACEMENT_MAP[placement]) return R.error(res, "Invalid placement.", 400); const advertisement = await Advertisement.findOne({ where: liveWhere({ placement }), order: [["order", "ASC"], ["createdAt", "DESC"]], include: [AD_IMAGE_INCLUDE], attributes: { exclude: AD_CLIENT_EXCLUDE }, }); 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 if (json.image) await attachImageStreamToken(json.image, req); 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); } }; // ─── GET ACTIVE (batch) ───────────────────────────────────────────────────── // // Resolves the highest-priority live advertisement for each of several // placements in a single round-trip — pages that need multiple simultaneous // slots (e.g. dashboard.hero + dashboard.popup) use this instead of N calls // to /active. // // GET /api/client/advertisements/active-batch?placements=dashboard.hero,dashboard.popup // exports.getActiveAdvertisements = async (req, res) => { try { const raw = req.query.placements; const placements = (Array.isArray(raw) ? raw : String(raw ?? "").split(",")) .map((p) => p.trim()) .filter(Boolean); if (!placements.length) return R.error(res, "placements is required.", 400); const invalid = placements.filter((p) => !PLACEMENT_MAP[p]); if (invalid.length) return R.error(res, `Invalid placement(s): ${invalid.join(", ")}`, 400); const advertisements = await Advertisement.findAll({ where: liveWhere({ placement: { [Op.in]: placements } }), order: [["order", "ASC"], ["createdAt", "DESC"]], include: [AD_IMAGE_INCLUDE], attributes: { exclude: AD_CLIENT_EXCLUDE }, }); // Keep only the highest-priority row per placement (order ASC, createdAt DESC already applied). const data = Object.fromEntries(placements.map((p) => [p, null])); for (const ad of advertisements) { const json = ad.toJSON(); if (data[json.placement] !== null) continue; // already have the winner for this placement json.status = deriveStatus(json); if (json.image) await attachImageStreamToken(json.image, req); data[json.placement] = json; } return R.success(res, "Active advertisements retrieved.", { data }); } catch (err) { console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE BATCH]", err); return R.error(res, "Could not retrieve advertisements.", 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 }); } };