mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
// 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 (list) ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolves every live advertisement for a single placement, ordered by
|
||||
// priority — used by carousel-style slots (e.g. dashboard.hero) that rotate
|
||||
// through several ads instead of showing only the single highest-priority one.
|
||||
//
|
||||
// GET /api/client/advertisements/active-list?placement=dashboard.hero&limit=8
|
||||
//
|
||||
exports.getActiveAdvertisementList = 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 limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 8, 1), 20);
|
||||
|
||||
const advertisements = await Advertisement.findAll({
|
||||
where: liveWhere({ placement }),
|
||||
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||
include: [AD_IMAGE_INCLUDE],
|
||||
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||
limit,
|
||||
});
|
||||
|
||||
const data = [];
|
||||
for (const ad of advertisements) {
|
||||
const json = ad.toJSON();
|
||||
json.status = deriveStatus(json);
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
data.push(json);
|
||||
}
|
||||
|
||||
return R.success(res, "Active advertisements retrieved.", { data });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE LIST]", err);
|
||||
return R.error(res, "Could not retrieve advertisements.", 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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET BY UUID ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolves a single live advertisement by uuid for its own landing page — the
|
||||
// destination CTA/banner clicks resolve to when the ad has no redirect_link
|
||||
// (see /ads/:uuid on the client).
|
||||
//
|
||||
// GET /api/client/advertisements/uuid/:uuid
|
||||
//
|
||||
exports.getAdvertisementByUuid = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
if (!uuid) return R.error(res, "uuid is required.", 400);
|
||||
|
||||
const advertisement = await Advertisement.findOne({
|
||||
where: liveWhere({ uuid }),
|
||||
include: [AD_IMAGE_INCLUDE],
|
||||
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
return R.success(res, "Advertisement retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][ADVERTISEMENT][GET BY UUID]", 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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user