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,
};