mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
revise ads and alerts
This commit is contained in:
@@ -61,8 +61,6 @@ function deriveStatus(advertisement) {
|
|||||||
return "active";
|
return "active";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_CTA_VARIANTS = ["default", "outline"];
|
|
||||||
|
|
||||||
function normalizeCtas(ctas) {
|
function normalizeCtas(ctas) {
|
||||||
if (!Array.isArray(ctas)) return [];
|
if (!Array.isArray(ctas)) return [];
|
||||||
return ctas
|
return ctas
|
||||||
@@ -71,12 +69,22 @@ function normalizeCtas(ctas) {
|
|||||||
.map((c, i) => ({
|
.map((c, i) => ({
|
||||||
label: c.label.trim(),
|
label: c.label.trim(),
|
||||||
link: c.link.trim(),
|
link: c.link.trim(),
|
||||||
// First CTA defaults to "default" (primary), second to "outline" — but
|
// Variant is always derived from position — first CTA is "default"
|
||||||
// an explicit, valid variant from the client always wins.
|
// (primary), second is "outline" — not user-selectable, so any
|
||||||
variant: ALLOWED_CTA_VARIANTS.includes(c.variant) ? c.variant : (i === 0 ? "default" : "outline"),
|
// 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) {
|
async function applyAdvertisementFields(advertisement, body) {
|
||||||
// placement is the only settable "where" — type/format is always derived
|
// placement is the only settable "where" — type/format is always derived
|
||||||
// from the placement's registry entry, never accepted directly from the body.
|
// 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;
|
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.headline !== undefined) advertisement.headline = body.headline;
|
||||||
if (body.description !== undefined) advertisement.description = body.description;
|
if (body.description !== undefined) advertisement.description = body.description;
|
||||||
if (body.image_url !== undefined) advertisement.image_url = body.image_url;
|
if (body.image_url !== undefined) advertisement.image_url = body.image_url;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const mediaToken = require('../../services/mediaToken.service');
|
|||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
|
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
|
||||||
|
|
||||||
const STICKY_LIMIT = 3;
|
const STICKY_LIMIT = 2;
|
||||||
const IMAGE_INCLUDE = {
|
const IMAGE_INCLUDE = {
|
||||||
model: mdl_Assets,
|
model: mdl_Assets,
|
||||||
as: 'image',
|
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.`;
|
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) {
|
async function validateImageAssetId(image_asset_id) {
|
||||||
@@ -97,7 +97,7 @@ async function propagateNotificationVisibility(where, { show_in_sticky, show_in_
|
|||||||
|
|
||||||
async function applyBroadcastFields(broadcast, body) {
|
async function applyBroadcastFields(broadcast, body) {
|
||||||
if (body.title !== undefined) broadcast.title = body.title;
|
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_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.link_label !== undefined) broadcast.link_label = body.link_label?.trim() || null;
|
||||||
if (body.color !== undefined) broadcast.color = body.color || 'indigo';
|
if (body.color !== undefined) broadcast.color = body.color || 'indigo';
|
||||||
@@ -254,7 +254,6 @@ exports.createBroadcast = async (req, res) => {
|
|||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!title) return R.error(res, "title is required.", 400);
|
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 (!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 (!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);
|
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) {
|
if (!showSticky && !showNotifs) {
|
||||||
return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400);
|
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)) {
|
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);
|
return R.error(res, "Start date must be before end date.", 400);
|
||||||
@@ -278,7 +283,7 @@ exports.createBroadcast = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const broadcast = await NotificationBroadcast.build({
|
const broadcast = await NotificationBroadcast.build({
|
||||||
title,
|
title,
|
||||||
message,
|
message: message || null,
|
||||||
link_url: link_url?.trim() || null,
|
link_url: link_url?.trim() || null,
|
||||||
link_label: link_label?.trim() || null,
|
link_label: link_label?.trim() || null,
|
||||||
color: color || 'indigo',
|
color: color || 'indigo',
|
||||||
@@ -328,6 +333,16 @@ exports.updateBroadcast = async (req, res) => {
|
|||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
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
|
// Editing a live broadcast to newly flip on show_in_sticky is the same
|
||||||
// "activate a sticky slot" action as sendBroadcast — must respect the
|
// "activate a sticky slot" action as sendBroadcast — must respect the
|
||||||
@@ -352,7 +367,9 @@ exports.updateBroadcast = async (req, res) => {
|
|||||||
if (broadcast.status === 'sent') {
|
if (broadcast.status === 'sent') {
|
||||||
const propagated = {
|
const propagated = {
|
||||||
title: broadcast.title,
|
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,
|
color: broadcast.color,
|
||||||
image_asset_id: broadcast.image_asset_id,
|
image_asset_id: broadcast.image_asset_id,
|
||||||
show_in_sticky: broadcast.show_in_sticky,
|
show_in_sticky: broadcast.show_in_sticky,
|
||||||
@@ -419,7 +436,7 @@ exports.sendBroadcast = async (req, res) => {
|
|||||||
|
|
||||||
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
|
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
|
||||||
title: broadcast.title,
|
title: broadcast.title,
|
||||||
message: broadcast.message,
|
message: broadcast.message || "",
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
targetId,
|
||||||
linkUrl: broadcast.link_url,
|
linkUrl: broadcast.link_url,
|
||||||
@@ -458,7 +475,7 @@ exports.sendBroadcast = async (req, res) => {
|
|||||||
user_id,
|
user_id,
|
||||||
...(targetType === 'task_list'
|
...(targetType === 'task_list'
|
||||||
? NOTIFICATION_REGISTRY.broadcast.build({
|
? 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,
|
groupId: groupByUser[user_id] ?? null,
|
||||||
linkUrl: broadcast.link_url,
|
linkUrl: broadcast.link_url,
|
||||||
linkLabel: broadcast.link_label,
|
linkLabel: broadcast.link_label,
|
||||||
|
|||||||
@@ -104,6 +104,19 @@ exports.captureCourseOrder = async (req, res) => {
|
|||||||
|
|
||||||
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
|
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({
|
await purchase.update({
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
paid_at: new Date(),
|
paid_at: new Date(),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const mediaToken = require('../../services/mediaToken.service');
|
|||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
|
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
|
||||||
|
|
||||||
const STICKY_LIMIT = 3;
|
const STICKY_LIMIT = 2;
|
||||||
const IMAGE_INCLUDE = {
|
const IMAGE_INCLUDE = {
|
||||||
model: mdl_Assets,
|
model: mdl_Assets,
|
||||||
as: 'image',
|
as: 'image',
|
||||||
|
|||||||
@@ -300,6 +300,19 @@ exports.captureOrder = async (req, res) => {
|
|||||||
|
|
||||||
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
|
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
|
// Repurchasing a plan under a tier already held active extends the
|
||||||
// existing grant's expires_at by the new plan's duration, rather than
|
// existing grant's expires_at by the new plan's duration, rather than
|
||||||
// being blocked/refunded — the original plan_id is kept (whichever plan
|
// being blocked/refunded — the original plan_id is kept (whichever plan
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
* - expireUserTiers (cron/jobs/expire_user_tiers.cron.js)
|
* - expireUserTiers (cron/jobs/expire_user_tiers.cron.js)
|
||||||
* - taskDueSoon (cron/jobs/task_due_soon.cron.js)
|
* - taskDueSoon (cron/jobs/task_due_soon.cron.js)
|
||||||
* - expireAdvertisements (cron/jobs/expire_advertisements.cron.js) — plain
|
* - expireAdvertisements (cron/jobs/expire_advertisements.cron.js) — plain
|
||||||
|
* - failStalePayments (cron/jobs/fail_stale_payments.cron.js) — plain
|
||||||
*
|
*
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jun. 17, 2026
|
* Date Created: Jun. 17, 2026
|
||||||
@@ -28,6 +29,7 @@ const issueCertificates = require('./jobs/issue_certificates.cron');
|
|||||||
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
||||||
const taskDueSoon = require('./jobs/task_due_soon.cron');
|
const taskDueSoon = require('./jobs/task_due_soon.cron');
|
||||||
const expireAdvertisements = require('./jobs/expire_advertisements.cron');
|
const expireAdvertisements = require('./jobs/expire_advertisements.cron');
|
||||||
|
const failStalePayments = require('./jobs/fail_stale_payments.cron');
|
||||||
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
||||||
|
|
||||||
// ─── Registry — add future client-side cron jobs here ────────────────────────
|
// ─── Registry — add future client-side cron jobs here ────────────────────────
|
||||||
@@ -41,6 +43,7 @@ const settingsBackedJobs = [
|
|||||||
// Plain hardcoded-schedule jobs (not tied to any notification setting).
|
// Plain hardcoded-schedule jobs (not tied to any notification setting).
|
||||||
const plainJobs = [
|
const plainJobs = [
|
||||||
expireAdvertisements,
|
expireAdvertisements,
|
||||||
|
failStalePayments,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Boot all registered client-side jobs ─────────────────────────────────────
|
// ─── Boot all registered client-side jobs ─────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name : fail_stale_payments.cron.js
|
||||||
|
* Type : Cron Job
|
||||||
|
* Description : Marks abandoned checkout attempts as 'failed' instead of
|
||||||
|
* leaving them stuck on 'pending' forever.
|
||||||
|
*
|
||||||
|
* A payment/purchase row is created as 'pending' the moment
|
||||||
|
* PayPal's create-order call succeeds (createOrder / createCourseOrder),
|
||||||
|
* before the buyer ever reaches PayPal's approval page. If PayPal's
|
||||||
|
* own hosted checkout then fails to load ("Things don't appear to
|
||||||
|
* be working at the moment") or the buyer just abandons the tab,
|
||||||
|
* the browser never gets redirected back to our return_url/cancel_url —
|
||||||
|
* so captureOrder/cancelOrder is never called, and the row sits as
|
||||||
|
* 'pending' indefinitely even though no money ever moved.
|
||||||
|
*
|
||||||
|
* This job sweeps 'pending' rows older than STALE_MINUTES and
|
||||||
|
* marks them 'failed', so payment history correctly reflects
|
||||||
|
* that nothing was charged, instead of silently doing nothing.
|
||||||
|
*
|
||||||
|
* Schedule : Every 10 minutes ("*\/10 * * * *"). Registered by cron/client.cron.js.
|
||||||
|
*
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Aug. 3, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||||
|
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
|
||||||
|
|
||||||
|
const STALE_MINUTES = 30;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const cutoff = new Date(Date.now() - STALE_MINUTES * 60_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stalePayments = await mdl_Payments.findAll({
|
||||||
|
where: { status: 'pending', createdAt: { [Op.lt]: cutoff } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const payment of stalePayments) {
|
||||||
|
await payment.update({
|
||||||
|
status: 'failed',
|
||||||
|
provider_payload: {
|
||||||
|
...payment.provider_payload,
|
||||||
|
failed_reason: 'abandoned_checkout',
|
||||||
|
marked_failed_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stalePayments.length) {
|
||||||
|
console.log(`[CRON][FAIL STALE PAYMENTS] Marked ${stalePayments.length} abandoned tier payment(s) as failed.`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CRON][FAIL STALE PAYMENTS] Failed sweeping payments:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stalePurchases = await mdl_CoursePurchase.findAll({
|
||||||
|
where: { status: 'pending', createdAt: { [Op.lt]: cutoff } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const purchase of stalePurchases) {
|
||||||
|
await purchase.update({
|
||||||
|
status: 'failed',
|
||||||
|
provider_payload: {
|
||||||
|
...purchase.provider_payload,
|
||||||
|
failed_reason: 'abandoned_checkout',
|
||||||
|
marked_failed_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stalePurchases.length) {
|
||||||
|
console.log(`[CRON][FAIL STALE PAYMENTS] Marked ${stalePurchases.length} abandoned course purchase(s) as failed.`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CRON][FAIL STALE PAYMENTS] Failed sweeping course purchases:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'failStalePayments',
|
||||||
|
schedule: '*/10 * * * *',
|
||||||
|
run,
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// badge_label was a single STRING(100). The Type step redesign lets admins
|
||||||
|
// attach up to 2 badges (outline chips) to a "Text with Image" ad, so the
|
||||||
|
// column becomes a JSONB string array (renamed badge_labels to match the
|
||||||
|
// plural shape, mirroring the existing `ctas` JSONB column). Any existing
|
||||||
|
// scalar value is wrapped into a single-element array; null/empty becomes [].
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE advertisements
|
||||||
|
ALTER COLUMN badge_label TYPE JSONB
|
||||||
|
USING (
|
||||||
|
CASE
|
||||||
|
WHEN badge_label IS NULL OR badge_label = '' THEN '[]'::jsonb
|
||||||
|
ELSE jsonb_build_array(badge_label)
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE advertisements
|
||||||
|
ALTER COLUMN badge_label SET DEFAULT '[]'::jsonb
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE advertisements
|
||||||
|
ALTER COLUMN badge_label SET NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.renameColumn('advertisements', 'badge_label', 'badge_labels');
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.renameColumn('advertisements', 'badge_labels', 'badge_label');
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE advertisements
|
||||||
|
ALTER COLUMN badge_label DROP NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE advertisements
|
||||||
|
ALTER COLUMN badge_label TYPE STRING(100)
|
||||||
|
USING (
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_array_length(badge_label) = 0 THEN NULL
|
||||||
|
ELSE badge_label->>0
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE advertisements
|
||||||
|
ALTER COLUMN badge_label DROP DEFAULT
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Alerts are title-only now — the composer no longer collects a body message
|
||||||
|
// (see AddNotificationBroadcast.jsx). Only this table's column goes; the
|
||||||
|
// per-recipient admin_notifications/user_notifications.message columns stay
|
||||||
|
// (shared by ~20 unrelated notification types via NOTIFICATION_REGISTRY).
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
await queryInterface.removeColumn('notification_broadcasts', 'message');
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn('notification_broadcasts', 'message', {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Notifications-type alerts need a body again; Sticky-type alerts stay
|
||||||
|
// title-only. Nullable this time (sticky rows never populate it) — reverses
|
||||||
|
// 20270101000086, which dropped this same column as NOT NULL.
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn('notification_broadcasts', 'message', {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.removeColumn('notification_broadcasts', 'message');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -33,6 +33,10 @@ const jsonbSchemas = {
|
|||||||
variant: "string", // "default" | "outline"
|
variant: "string", // "default" | "outline"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
badge_labels: {
|
||||||
|
type: "array",
|
||||||
|
itemShape: "string",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Computed attributes ──────────────────────────────────────────────────────
|
// ─── Computed attributes ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ const Advertisement = sequelize.define("Advertisement", {
|
|||||||
// the image — an explicit admin choice made in the creation wizard, decoupled
|
// the image — an explicit admin choice made in the creation wizard, decoupled
|
||||||
// from placement/format.
|
// from placement/format.
|
||||||
content_mode: { type: DataTypes.ENUM("image", "content"), allowNull: false, defaultValue: "image", label: "Content Mode", order: 3.5 },
|
content_mode: { type: DataTypes.ENUM("image", "content"), allowNull: false, defaultValue: "image", label: "Content Mode", order: 3.5 },
|
||||||
badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 4 },
|
// Up to 2 outline-badge chips shown alongside the headline — see MAX_BADGE_LABELS.
|
||||||
|
badge_labels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "Badge Labels", order: 4 },
|
||||||
headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 },
|
headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 },
|
||||||
description: { type: DataTypes.TEXT, label: "Description", order: 6 },
|
description: { type: DataTypes.TEXT, label: "Description", order: 6 },
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
|
|||||||
|
|
||||||
// ─── Content ──────────────────────────────────────────────────────────────
|
// ─── Content ──────────────────────────────────────────────────────────────
|
||||||
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 },
|
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 },
|
||||||
message: { type: DataTypes.TEXT, allowNull: false, label: "Message", order: 2 },
|
// Only used by Notifications-type alerts (show_in_notifications) — Sticky
|
||||||
|
// alerts (show_in_sticky) are title-only and leave this null.
|
||||||
|
message: { type: DataTypes.TEXT, allowNull: true, label: "Message", order: 1.5 },
|
||||||
// When set, the client's "view full content" dialog shows an "Open Link"
|
// When set, the client's "view full content" dialog shows an "Open Link"
|
||||||
// action pointing here. When null, that dialog is plain text info only.
|
// action pointing here. When null, that dialog is plain text info only.
|
||||||
link_url: { type: DataTypes.STRING(2048), allowNull: true, label: "Link URL", order: 2.2 },
|
link_url: { type: DataTypes.STRING(2048), allowNull: true, label: "Link URL", order: 2.2 },
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Regression test for the "declined payment still granted access" bug ──────
|
||||||
|
// PayPal's v2 capture-order endpoint can respond with HTTP 2xx even when the
|
||||||
|
// charge itself was declined or held for review — the failure only shows up
|
||||||
|
// in the response body's status field (e.g. capture.status: "DECLINED" or
|
||||||
|
// "PENDING"), not as a thrown HTTP error. Before the fix, captureOrder() only
|
||||||
|
// guarded against a *thrown* error from the provider and unconditionally
|
||||||
|
// granted the tier on any non-throwing response. These tests pin the fix:
|
||||||
|
// a non-COMPLETED capture must mark the payment failed and must NOT create
|
||||||
|
// or extend a user_tiers row.
|
||||||
|
|
||||||
|
jest.mock('../../models/tiers/tier_categories.mdl', () => ({}));
|
||||||
|
jest.mock('../../models/tiers/tier_plans.mdl', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../../models/tiers/user_tiers.mdl', () => ({ findOne: jest.fn(), create: jest.fn() }));
|
||||||
|
jest.mock('../../models/tiers/payments.mdl', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../../models/system_badges/system_badges.mdl', () => ({}));
|
||||||
|
jest.mock('../../models/assets/assets.mdl', () => ({}));
|
||||||
|
jest.mock('../../models/notifications/user_notification.mdl', () => ({ create: jest.fn() }));
|
||||||
|
jest.mock('../../services/achievements.service', () => ({ onTierActivated: jest.fn() }));
|
||||||
|
jest.mock('../../models/courses/courses.mdl', () => ({ Course: {} }));
|
||||||
|
jest.mock('../../services/payment.service', () => ({ captureOrder: jest.fn() }));
|
||||||
|
jest.mock('../../data/notifications.data', () => ({ NOTIFICATION_REGISTRY: {} }));
|
||||||
|
jest.mock('../../models/tiers/tier.associations', () => ({}));
|
||||||
|
|
||||||
|
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||||
|
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||||
|
const paymentSvc = require('../../services/payment.service');
|
||||||
|
const controller = require('../../controllers/client/tiers.controller');
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
const res = {};
|
||||||
|
res.status = jest.fn().mockReturnValue(res);
|
||||||
|
res.json = jest.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePayment(overrides = {}) {
|
||||||
|
return {
|
||||||
|
payment_id: 1,
|
||||||
|
plan_id: 10,
|
||||||
|
provider: 'paypal',
|
||||||
|
provider_payload: { order_id: 'ORDER-1' },
|
||||||
|
plan: { plan_id: 10, tier: 'premium', duration_days: 30, is_active: true },
|
||||||
|
update: jest.fn().mockResolvedValue(true),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
describe('captureOrder() — declined/incomplete captures must not grant access', () => {
|
||||||
|
|
||||||
|
test('capture.status "DECLINED" marks the payment failed and creates no tier', async () => {
|
||||||
|
const payment = makePayment();
|
||||||
|
mdl_Payments.findOne.mockResolvedValue(payment);
|
||||||
|
paymentSvc.captureOrder.mockResolvedValue({
|
||||||
|
status: 'COMPLETED', // outer order status can still say COMPLETED
|
||||||
|
purchase_units: [{ payments: { captures: [{ id: 'CAP-1', status: 'DECLINED' }] } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = { user: { user_id: 1 }, body: { order_id: 'ORDER-1' } };
|
||||||
|
const res = mockRes();
|
||||||
|
|
||||||
|
await controller.captureOrder(req, res);
|
||||||
|
|
||||||
|
expect(mdl_UserTiers.create).not.toHaveBeenCalled();
|
||||||
|
expect(payment.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'failed' })
|
||||||
|
);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(402);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capture.status "PENDING" (e.g. eCheck review) also withholds access', async () => {
|
||||||
|
const payment = makePayment();
|
||||||
|
mdl_Payments.findOne.mockResolvedValue(payment);
|
||||||
|
paymentSvc.captureOrder.mockResolvedValue({
|
||||||
|
status: 'COMPLETED',
|
||||||
|
purchase_units: [{ payments: { captures: [{ id: 'CAP-2', status: 'PENDING' }] } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = { user: { user_id: 1 }, body: { order_id: 'ORDER-1' } };
|
||||||
|
const res = mockRes();
|
||||||
|
|
||||||
|
await controller.captureOrder(req, res);
|
||||||
|
|
||||||
|
expect(mdl_UserTiers.create).not.toHaveBeenCalled();
|
||||||
|
expect(payment.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'failed' })
|
||||||
|
);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(402);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capture.status "COMPLETED" still grants the tier (control case)', async () => {
|
||||||
|
const payment = makePayment();
|
||||||
|
mdl_Payments.findOne.mockResolvedValue(payment);
|
||||||
|
mdl_UserTiers.findOne.mockResolvedValue(null); // no existing active tier
|
||||||
|
mdl_UserTiers.create.mockResolvedValue({ tier_id: 99, tier: 'premium', expires_at: new Date() });
|
||||||
|
paymentSvc.captureOrder.mockResolvedValue({
|
||||||
|
status: 'COMPLETED',
|
||||||
|
payer: { payer_id: 'PAYER-1' },
|
||||||
|
purchase_units: [{ payments: { captures: [{ id: 'CAP-3', status: 'COMPLETED' }] } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = { user: { user_id: 1 }, body: { order_id: 'ORDER-1' } };
|
||||||
|
const res = mockRes();
|
||||||
|
|
||||||
|
await controller.captureOrder(req, res);
|
||||||
|
|
||||||
|
expect(mdl_UserTiers.create).toHaveBeenCalled();
|
||||||
|
expect(payment.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'completed' })
|
||||||
|
);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
jest.mock('../../models/tiers/payments.mdl', () => ({ findAll: jest.fn() }));
|
||||||
|
jest.mock('../../models/courses/course_purchases.mdl', () => ({ findAll: jest.fn() }));
|
||||||
|
|
||||||
|
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||||
|
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
|
||||||
|
const job = require('../../cron/jobs/fail_stale_payments.cron');
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
test('marks stale pending payments and purchases as failed', async () => {
|
||||||
|
const payment = { provider_payload: { order_id: 'O-1' }, update: jest.fn().mockResolvedValue(true) };
|
||||||
|
const purchase = { provider_payload: { order_id: 'O-2' }, update: jest.fn().mockResolvedValue(true) };
|
||||||
|
|
||||||
|
mdl_Payments.findAll.mockResolvedValue([payment]);
|
||||||
|
mdl_CoursePurchase.findAll.mockResolvedValue([purchase]);
|
||||||
|
|
||||||
|
await job.run();
|
||||||
|
|
||||||
|
expect(payment.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'failed',
|
||||||
|
provider_payload: expect.objectContaining({ failed_reason: 'abandoned_checkout' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(purchase.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'failed',
|
||||||
|
provider_payload: expect.objectContaining({ failed_reason: 'abandoned_checkout' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does nothing when there are no stale rows', async () => {
|
||||||
|
mdl_Payments.findAll.mockResolvedValue([]);
|
||||||
|
mdl_CoursePurchase.findAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(job.run()).resolves.not.toThrow();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user