mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
535 lines
25 KiB
JavaScript
535 lines
25 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 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";
|
|
}
|
|
|
|
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) {
|
|
// 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.
|
|
|
|
if (body.content_mode !== undefined) {
|
|
if (!["image", "content"].includes(body.content_mode)) {
|
|
const err = new Error(`Invalid content_mode. Must be one of: image, content`);
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
advertisement.content_mode = body.content_mode;
|
|
}
|
|
|
|
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.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;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}; |