/*********************************************************************************************************************************************************************** * 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: { purchasable_type: 'course', purchasable_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, };