revise ads and alerts

This commit is contained in:
2026-08-03 22:48:06 +08:00
parent 32c72f0b39
commit 573cfb98af
16 changed files with 414 additions and 17 deletions
+14 -6
View File
@@ -61,8 +61,6 @@ function deriveStatus(advertisement) {
return "active";
}
const ALLOWED_CTA_VARIANTS = ["default", "outline"];
function normalizeCtas(ctas) {
if (!Array.isArray(ctas)) return [];
return ctas
@@ -71,12 +69,22 @@ function normalizeCtas(ctas) {
.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"),
// 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.
@@ -103,7 +111,7 @@ async function applyAdvertisementFields(advertisement, body) {
advertisement.content_mode = body.content_mode;
}
if (body.badge_label !== undefined) advertisement.badge_label = body.badge_label;
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;
+1 -1
View File
@@ -17,7 +17,7 @@ const mediaToken = require('../../services/mediaToken.service');
const R = require('../../utils/response.util');
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
const STICKY_LIMIT = 3;
const STICKY_LIMIT = 2;
const IMAGE_INCLUDE = {
model: mdl_Assets,
as: 'image',
@@ -66,7 +66,7 @@ async function countActiveSticky(excludeId = null) {
});
}
const MAX_ACTIVE_STICKY = 3;
const MAX_ACTIVE_STICKY = 2;
const ACTIVE_STICKY_CAP_MESSAGE = `Maximum of ${MAX_ACTIVE_STICKY} active sticky alerts right now — this stays in Draft until one ends or is archived.`;
async function validateImageAssetId(image_asset_id) {
@@ -97,7 +97,7 @@ async function propagateNotificationVisibility(where, { show_in_sticky, show_in_
async function applyBroadcastFields(broadcast, body) {
if (body.title !== undefined) broadcast.title = body.title;
if (body.message !== undefined) broadcast.message = body.message;
if (body.message !== undefined) broadcast.message = body.message || null;
if (body.link_url !== undefined) broadcast.link_url = body.link_url?.trim() || null;
if (body.link_label !== undefined) broadcast.link_label = body.link_label?.trim() || null;
if (body.color !== undefined) broadcast.color = body.color || 'indigo';
@@ -254,7 +254,6 @@ exports.createBroadcast = async (req, res) => {
} = req.body;
if (!title) return R.error(res, "title is required.", 400);
if (!message) return R.error(res, "message is required.", 400);
if (!target_type) return R.error(res, "target_type is required.", 400);
if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`, 400);
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, "target_id is required for this target_type.", 400);
@@ -265,6 +264,12 @@ exports.createBroadcast = async (req, res) => {
if (!showSticky && !showNotifs) {
return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400);
}
if (showSticky && showNotifs) {
return R.error(res, "Choose only one: Sticky or Notifications.", 400);
}
if (showNotifs && !message) {
return R.error(res, "message is required for Notifications alerts.", 400);
}
if (start_date && end_date && new Date(start_date) > new Date(end_date)) {
return R.error(res, "Start date must be before end date.", 400);
@@ -278,7 +283,7 @@ exports.createBroadcast = async (req, res) => {
try {
const broadcast = await NotificationBroadcast.build({
title,
message,
message: message || null,
link_url: link_url?.trim() || null,
link_label: link_label?.trim() || null,
color: color || 'indigo',
@@ -328,6 +333,16 @@ exports.updateBroadcast = async (req, res) => {
err.status = 400;
throw err;
}
if (broadcast.show_in_sticky && broadcast.show_in_notifications) {
const err = new Error("Choose only one: Sticky or Notifications.");
err.status = 400;
throw err;
}
if (broadcast.show_in_notifications && !broadcast.message) {
const err = new Error("message is required for Notifications alerts.");
err.status = 400;
throw err;
}
// Editing a live broadcast to newly flip on show_in_sticky is the same
// "activate a sticky slot" action as sendBroadcast — must respect the
@@ -352,7 +367,9 @@ exports.updateBroadcast = async (req, res) => {
if (broadcast.status === 'sent') {
const propagated = {
title: broadcast.title,
message: broadcast.message,
// admin_notifications/user_notifications.message stays NOT NULL —
// sticky-mode broadcasts have a null message here, so fall back to "".
message: broadcast.message || "",
color: broadcast.color,
image_asset_id: broadcast.image_asset_id,
show_in_sticky: broadcast.show_in_sticky,
@@ -419,7 +436,7 @@ exports.sendBroadcast = async (req, res) => {
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
title: broadcast.title,
message: broadcast.message,
message: broadcast.message || "",
targetType,
targetId,
linkUrl: broadcast.link_url,
@@ -458,7 +475,7 @@ exports.sendBroadcast = async (req, res) => {
user_id,
...(targetType === 'task_list'
? NOTIFICATION_REGISTRY.broadcast.build({
title: broadcast.title, message: broadcast.message, targetType, targetId,
title: broadcast.title, message: broadcast.message || "", targetType, targetId,
groupId: groupByUser[user_id] ?? null,
linkUrl: broadcast.link_url,
linkLabel: broadcast.link_label,
@@ -104,6 +104,19 @@ exports.captureCourseOrder = async (req, res) => {
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// PayPal can return an HTTP 2xx from the capture endpoint even when the
// charge itself was declined or held for review (e.g. capture.status
// "DECLINED"/"PENDING") — axios only throws on non-2xx, so the actual
// status field must be checked explicitly before granting any access.
const captureStatus = capture?.status ?? captureData.status;
if (captureStatus !== 'COMPLETED') {
await purchase.update({
status: 'failed',
provider_payload: { ...purchase.provider_payload, capture: captureData, failed_reason: captureStatus ?? 'unknown' },
});
return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402);
}
await purchase.update({
status: 'completed',
paid_at: new Date(),
@@ -17,7 +17,7 @@ const mediaToken = require('../../services/mediaToken.service');
const R = require('../../utils/response.util');
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
const STICKY_LIMIT = 3;
const STICKY_LIMIT = 2;
const IMAGE_INCLUDE = {
model: mdl_Assets,
as: 'image',
+13
View File
@@ -300,6 +300,19 @@ exports.captureOrder = async (req, res) => {
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// PayPal can return an HTTP 2xx from the capture endpoint even when the
// charge itself was declined or held for review (e.g. capture.status
// "DECLINED"/"PENDING") — axios only throws on non-2xx, so the actual
// status field must be checked explicitly before granting any access.
const captureStatus = capture?.status ?? captureData.status;
if (captureStatus !== 'COMPLETED') {
await payment.update({
status: 'failed',
provider_payload: { ...payment.provider_payload, capture: captureData, failed_reason: captureStatus ?? 'unknown' },
});
return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402);
}
// Repurchasing a plan under a tier already held active extends the
// existing grant's expires_at by the new plan's duration, rather than
// being blocked/refunded — the original plan_id is kept (whichever plan