new commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:20:58 +08:00
parent 1372f4e975
commit 1d12f04967
93 changed files with 3849 additions and 1063 deletions
+153
View File
@@ -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,
};
-171
View File
@@ -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,
};
+21
View File
@@ -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 };