mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,153 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: audienceResolver.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: Shared "who does this target reach" resolution for anything
|
||||
* broadcast-shaped (notification broadcasts, email broadcasts).
|
||||
* Extracted out of controllers/admin/notificationBroadcasts.controller.js
|
||||
* so both features resolve task_list/course/tier_plan targeting
|
||||
* identically instead of drifting apart.
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 3, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op, QueryTypes } = require('sequelize');
|
||||
const sequelize = require('../config/db.config');
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const { TaskList } = require('../models/task/task.mdl');
|
||||
const { Course } = require('../models/courses/courses.mdl');
|
||||
const mdl_TierCategories = require('../models/tiers/tier_categories.mdl');
|
||||
const mdl_UserTiers = require('../models/tiers/user_tiers.mdl');
|
||||
const mdl_TierPlans = require('../models/tiers/tier_plans.mdl');
|
||||
const mdl_Product = require('../models/courses/products.mdl');
|
||||
const mdl_CoursePurchase = require('../models/courses/course_purchases.mdl');
|
||||
|
||||
const ALLOWED_TARGET_TYPES = ["admin", "user", "both", "task_list", "course", "tier_plan"];
|
||||
const SCOPED_TARGET_TYPES = ["task_list", "course", "tier_plan"];
|
||||
|
||||
async function validateTargetId(target_type, target_id) {
|
||||
if (target_type === "task_list") {
|
||||
const row = await TaskList.findOne({ where: { task_list_id: target_id, deletedAt: null } });
|
||||
if (!row) { const err = new Error("Selected task list was not found."); err.status = 400; throw err; }
|
||||
} else if (target_type === "course") {
|
||||
const row = await Course.findOne({ where: { uuid: target_id, deletedAt: null } });
|
||||
if (!row) { const err = new Error("Selected course was not found."); err.status = 400; throw err; }
|
||||
} else if (target_type === "tier_plan") {
|
||||
const row = await mdl_TierPlans.findOne({ where: { plan_id: target_id, deletedAt: null } });
|
||||
if (!row) { const err = new Error("Selected tier plan was not found."); err.status = 400; throw err; }
|
||||
}
|
||||
}
|
||||
|
||||
// Rank-0 (free) courses resolve like target_type: 'user' — everyone qualifies.
|
||||
async function resolveCourseUserIds(courseUuid) {
|
||||
const course = await Course.findOne({ where: { uuid: courseUuid, deletedAt: null }, attributes: ['course_id', 'subscription'] });
|
||||
if (!course) return [];
|
||||
|
||||
// rank is BIGINT on CockroachDB — Sequelize returns it as a string, so normalize to Number.
|
||||
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
|
||||
const rankMap = Object.fromEntries(categories.map((c) => [c.slug, Number(c.rank)]));
|
||||
const courseRank = rankMap[course.subscription] ?? Infinity;
|
||||
|
||||
const userIds = new Set();
|
||||
|
||||
if (courseRank === 0) {
|
||||
const users = await mdl_Users.findAll({ attributes: ['user_id'], where: { acc_type: 'user', deletedAt: null }, raw: true });
|
||||
users.forEach((u) => userIds.add(String(u.user_id)));
|
||||
return [...userIds];
|
||||
}
|
||||
|
||||
const qualifyingSlugs = Object.entries(rankMap).filter(([, rank]) => rank >= courseRank).map(([slug]) => slug);
|
||||
if (qualifyingSlugs.length) {
|
||||
const holders = await mdl_UserTiers.findAll({
|
||||
attributes: ['user_id'],
|
||||
where: { status: 'active', tier: { [Op.in]: qualifyingSlugs } },
|
||||
raw: true,
|
||||
});
|
||||
holders.forEach((h) => userIds.add(String(h.user_id)));
|
||||
}
|
||||
|
||||
const product = await mdl_Product.findOne({ where: { course_id: course.course_id } });
|
||||
if (product) {
|
||||
const purchasers = await mdl_CoursePurchase.findAll({
|
||||
attributes: ['user_id'],
|
||||
where: {
|
||||
product_id: product.id,
|
||||
status: 'completed',
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
raw: true,
|
||||
});
|
||||
purchasers.forEach((p) => userIds.add(String(p.user_id)));
|
||||
}
|
||||
|
||||
return [...userIds];
|
||||
}
|
||||
|
||||
async function resolveTaskListUserIds(taskListId) {
|
||||
const rows = await sequelize.query(
|
||||
`SELECT DISTINCT ugm.user_id
|
||||
FROM task_list_groups tlg
|
||||
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id AND ugm."deletedAt" IS NULL
|
||||
WHERE tlg.task_list_id = :taskListId`,
|
||||
{ replacements: { taskListId }, type: QueryTypes.SELECT }
|
||||
);
|
||||
return rows.map((r) => String(r.user_id));
|
||||
}
|
||||
|
||||
// user_id → group_id, for deep-linking task_list broadcasts to /group/:groupId/view/:taskListId.
|
||||
// First matching group wins if a user belongs to more than one group tied to the task list.
|
||||
async function resolveTaskListUserGroups(taskListId) {
|
||||
const rows = await sequelize.query(
|
||||
`SELECT ugm.user_id, tlg.group_id
|
||||
FROM task_list_groups tlg
|
||||
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id AND ugm."deletedAt" IS NULL
|
||||
WHERE tlg.task_list_id = :taskListId`,
|
||||
{ replacements: { taskListId }, type: QueryTypes.SELECT }
|
||||
);
|
||||
const map = {};
|
||||
for (const r of rows) {
|
||||
const uid = String(r.user_id);
|
||||
if (!(uid in map)) map[uid] = r.group_id;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function resolveTierPlanUserIds(planId) {
|
||||
const holders = await mdl_UserTiers.findAll({
|
||||
attributes: ['user_id'],
|
||||
where: { plan_id: planId, status: 'active' },
|
||||
raw: true,
|
||||
});
|
||||
return holders.map((h) => String(h.user_id));
|
||||
}
|
||||
|
||||
// Dispatcher for the 3 "scoped" target types only — 'admin'/'user'/'both' mean
|
||||
// different things to different callers (e.g. notification broadcasts post to
|
||||
// the shared admin bell feed for 'admin'; email broadcasts email every admin/
|
||||
// staff user instead), so those stay caller-specific rather than living here.
|
||||
async function resolveTargetUserIds(target_type, target_id) {
|
||||
if (target_type === 'task_list') return resolveTaskListUserIds(target_id);
|
||||
if (target_type === 'course') return resolveCourseUserIds(target_id);
|
||||
if (target_type === 'tier_plan') return resolveTierPlanUserIds(target_id);
|
||||
return [];
|
||||
}
|
||||
|
||||
async function resolveAllUserIds({ transaction } = {}) {
|
||||
const users = await mdl_Users.findAll({
|
||||
attributes: ['user_id'],
|
||||
where: { acc_type: 'user', deletedAt: null },
|
||||
raw: true,
|
||||
transaction,
|
||||
});
|
||||
return users.map((u) => String(u.user_id));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ALLOWED_TARGET_TYPES,
|
||||
SCOPED_TARGET_TYPES,
|
||||
validateTargetId,
|
||||
resolveCourseUserIds,
|
||||
resolveTaskListUserIds,
|
||||
resolveTaskListUserGroups,
|
||||
resolveTierPlanUserIds,
|
||||
resolveTargetUserIds,
|
||||
resolveAllUserIds,
|
||||
};
|
||||
@@ -1,171 +0,0 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: currency.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: Currency formatting and resolution helpers for backend use (emails, receipts, notifications).
|
||||
*
|
||||
* All format functions accept an optional options object: { locale }
|
||||
* locale — BCP 47 tag, defaults to 'en-US'
|
||||
*
|
||||
* USD is the platform's base/canonical currency. Plans may carry localized price
|
||||
* overrides (plan_prices table). resolvePrice() applies the COALESCE logic:
|
||||
* localized override wins → falls back to plan's base price + currency.
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
// ─── Supported currencies ─────────────────────────────────────────────────────
|
||||
|
||||
const SUPPORTED_CURRENCIES = [
|
||||
{ code: 'USD', name: 'US Dollar', symbol: '$' },
|
||||
{ code: 'EUR', name: 'Euro', symbol: '€' },
|
||||
{ code: 'GBP', name: 'British Pound', symbol: '£' },
|
||||
{ code: 'CNY', name: 'Chinese Yuan', symbol: '¥' },
|
||||
{ code: 'JPY', name: 'Japanese Yen', symbol: '¥' },
|
||||
{ code: 'PHP', name: 'Philippine Peso', symbol: '₱' },
|
||||
{ code: 'KRW', name: 'South Korean Won', symbol: '₩' },
|
||||
{ code: 'AUD', name: 'Australian Dollar', symbol: 'A$' },
|
||||
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' },
|
||||
{ code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' },
|
||||
{ code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$'},
|
||||
{ code: 'INR', name: 'Indian Rupee', symbol: '₹' },
|
||||
{ code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM' },
|
||||
{ code: 'THB', name: 'Thai Baht', symbol: '฿' },
|
||||
{ code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' },
|
||||
{ code: 'TWD', name: 'Taiwan Dollar', symbol: 'NT$'},
|
||||
{ code: 'VND', name: 'Vietnamese Dong', symbol: '₫' },
|
||||
];
|
||||
|
||||
const SUPPORTED_CURRENCY_CODES = new Set(SUPPORTED_CURRENCIES.map((c) => c.code));
|
||||
|
||||
function isSupported(code) {
|
||||
return SUPPORTED_CURRENCY_CODES.has(code?.toUpperCase());
|
||||
}
|
||||
|
||||
// ─── Formatting ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** "¥1,299.00" / "$9.99" */
|
||||
function fmtCurrency(amount, currency = 'USD', { locale = 'en-US' } = {}) {
|
||||
if (amount === null || amount === undefined) return '—';
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency ?? 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(Number(amount));
|
||||
}
|
||||
|
||||
// ─── Price resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the effective { price, currency } for a plan given a user's preferred currency.
|
||||
* plan.prices must be eager-loaded (as: 'prices') for the override to be considered.
|
||||
* Falls back to plan.price + plan.currency when no override exists.
|
||||
*/
|
||||
function resolvePrice(plan, preferredCurrency) {
|
||||
if (!preferredCurrency || preferredCurrency === plan.currency)
|
||||
return { price: Number(plan.price), currency: plan.currency };
|
||||
|
||||
const override = (plan.prices ?? []).find((p) => p.currency === preferredCurrency);
|
||||
if (override) return { price: Number(override.price), currency: override.currency };
|
||||
|
||||
return { price: Number(plan.price), currency: plan.currency };
|
||||
}
|
||||
|
||||
// ─── Exchange rate fetching ───────────────────────────────────────────────────
|
||||
// Uses frankfurter.app (ECB-backed, no API key, free).
|
||||
// In-process cache with 1-hour TTL avoids hammering the API on every save.
|
||||
|
||||
const _rateCache = new Map();
|
||||
|
||||
async function fetchExchangeRate(from, to) {
|
||||
if (from === to) return 1;
|
||||
const key = `${from}:${to}`;
|
||||
const now = Date.now();
|
||||
const cached = _rateCache.get(key);
|
||||
if (cached && cached.expiresAt > now) return cached.rate;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.frankfurter.app/latest?from=${from}&to=${to}`,
|
||||
{ signal: AbortSignal.timeout(4000) },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
const rate = json?.rates?.[to];
|
||||
if (!rate) return null;
|
||||
_rateCache.set(key, { rate, expiresAt: now + 60 * 60 * 1000 }); // 1 h TTL
|
||||
return rate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Localized price validation ───────────────────────────────────────────────
|
||||
// Three zones relative to the market-rate conversion of the base price:
|
||||
//
|
||||
// pass → 85 % – 150 % of expected (green, saves normally)
|
||||
// warn → 70 % – 85 % or 150 % – 300 % (saves with caution message)
|
||||
// block → < 70 % or > 300 % (rejected — too far from market rate)
|
||||
//
|
||||
// If the exchange-rate API is unavailable the check is skipped (returns 'pass').
|
||||
|
||||
const PRICE_ZONES = {
|
||||
LOWER_HARD: 0.70,
|
||||
LOWER_WARN: 0.85,
|
||||
UPPER_WARN: 1.50,
|
||||
UPPER_HARD: 3.00,
|
||||
};
|
||||
|
||||
async function validateLocalizedPrice(basePrice, baseCurrency, localizedPrice, targetCurrency) {
|
||||
const rate = await fetchExchangeRate(baseCurrency, targetCurrency);
|
||||
if (!rate) return { zone: 'pass', skipped: true };
|
||||
|
||||
const expected = Number(basePrice) * rate;
|
||||
const entered = Number(localizedPrice);
|
||||
const { LOWER_HARD, LOWER_WARN, UPPER_WARN, UPPER_HARD } = PRICE_ZONES;
|
||||
|
||||
const hardMin = expected * LOWER_HARD;
|
||||
const hardMax = expected * UPPER_HARD;
|
||||
const warnMin = expected * LOWER_WARN;
|
||||
const warnMax = expected * UPPER_WARN;
|
||||
|
||||
const fmt = (n) => n.toFixed(2);
|
||||
const rateStr = `1 ${baseCurrency} = ${rate} ${targetCurrency}`;
|
||||
|
||||
if (entered < hardMin || entered > hardMax) {
|
||||
return {
|
||||
zone: 'block',
|
||||
expected: fmt(expected),
|
||||
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
|
||||
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
|
||||
message: `${fmt(entered)} ${targetCurrency} is too far from the current market rate (${rateStr}). ` +
|
||||
`Acceptable range: ${fmt(hardMin)} – ${fmt(hardMax)} ${targetCurrency}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (entered < warnMin || entered > warnMax) {
|
||||
return {
|
||||
zone: 'warn',
|
||||
expected: fmt(expected),
|
||||
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
|
||||
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
|
||||
message: `${fmt(entered)} ${targetCurrency} is outside the suggested range (${rateStr}). ` +
|
||||
`Suggested: ${fmt(warnMin)} – ${fmt(warnMax)} ${targetCurrency}. Saved with caution.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
zone: 'pass',
|
||||
expected: fmt(expected),
|
||||
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
|
||||
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUPPORTED_CURRENCIES,
|
||||
isSupported,
|
||||
fmtCurrency,
|
||||
resolvePrice,
|
||||
fetchExchangeRate,
|
||||
validateLocalizedPrice,
|
||||
PRICE_ZONES,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: renderTemplate.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: Plain-text {{placeholder}} substitution — no eval/Function, so
|
||||
* admin-supplied HTML can never execute arbitrary JS. Unknown or
|
||||
* missing keys resolve to an empty string rather than throwing.
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 3, 2026
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { renderTemplate } = require('../utils/renderTemplate.util');
|
||||
* renderTemplate('Hi {{name}}', { name: 'Ken' }); // "Hi Ken"
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
const renderTemplate = (str, data = {}) =>
|
||||
String(str ?? '').replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => {
|
||||
const val = data[key];
|
||||
return (val === undefined || val === null) ? '' : String(val);
|
||||
});
|
||||
|
||||
module.exports = { renderTemplate };
|
||||
Reference in New Issue
Block a user