tier plans improving

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-01 23:02:31 +08:00
parent cae958b5d5
commit c5052fda4c
15 changed files with 371 additions and 41 deletions
@@ -44,7 +44,7 @@ exports.getCategory = async (req, res) => {
exports.createCategory = async (req, res) => {
try {
const { slug, name, description, rank, color, badge_asset_id, badge_icon, badge_label } = req.body;
const { slug, name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_special } = req.body;
if (!slug || !name) return R.error(res, 'slug and name are required.', 400);
const parsedRank = Number(rank ?? 1);
@@ -63,6 +63,7 @@ exports.createCategory = async (req, res) => {
badge_label: badge_label ?? null,
is_default: false,
is_active: true,
is_special: !!is_special,
});
logActivity(req.user?.user_id, 'create_tier_category', { entityType: 'tier_category', details: { slug, name } });
@@ -82,7 +83,7 @@ exports.updateCategory = async (req, res) => {
const cat = await mdl_TierCategories.findByPk(req.params.id);
if (!cat) return R.error(res, 'Tier category not found.', 404);
const { name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_active } = req.body;
const { name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_active, is_special } = req.body;
if (!cat.is_default && rank !== undefined) {
const parsedRank = Number(rank);
@@ -98,7 +99,8 @@ exports.updateCategory = async (req, res) => {
badge_icon: badge_icon !== undefined ? (badge_icon || null) : cat.badge_icon,
badge_label: badge_label !== undefined ? (badge_label || null) : cat.badge_label,
// Default category (free) cannot be deactivated
is_active: (!cat.is_default && is_active !== undefined) ? is_active : cat.is_active,
is_active: (!cat.is_default && is_active !== undefined) ? is_active : cat.is_active,
is_special: is_special !== undefined ? !!is_special : cat.is_special,
});
logActivity(req.user?.user_id, 'update_tier_category', { entityType: 'tier_category', details: { id: cat.tier_category_id, slug: cat.slug } });
@@ -15,8 +15,11 @@ const VALID_RULE_TYPES = new Set([
'course_subscription_access',
'required_active_tier',
'group_restriction',
'item_allowlist',
]);
const VALID_ITEM_TYPES = new Set(['course', 'unit', 'lesson']);
async function validateRules(rules) {
if (!Array.isArray(rules)) return 'access_rules must be an array.';
@@ -41,6 +44,15 @@ async function validateRules(rules) {
if (!Array.isArray(rule.group_ids))
return 'group_restriction.group_ids must be an array.';
}
if (rule.type === 'item_allowlist') {
if (!VALID_ITEM_TYPES.has(rule.item_type))
return `item_allowlist.item_type must be one of: ${[...VALID_ITEM_TYPES].join(', ')}.`;
if (!Array.isArray(rule.item_ids) || !rule.item_ids.length)
return 'item_allowlist.item_ids must be a non-empty array.';
if (rule.item_ids.length > 3)
return 'item_allowlist.item_ids must contain at most 3 items.';
}
}
return null;
}
+46 -3
View File
@@ -28,6 +28,9 @@ const R = require('../../utils/response.util');
const { paginate } = require('../../utils/paginate.util');
const { getFieldValues } = require('../../utils/fieldValues.util');
const logActivity = require('../../utils/logActivity.util');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { resolveTierPlanUserIds } = require('../../utils/audienceResolver.util');
const {
excludeAttributes: plansExclude,
@@ -75,6 +78,7 @@ exports.getPlans = async (req, res) => {
jsonbSchemas: plansSchemas,
computedAttributes: plansComputed,
context: archived ? 'archived' : 'list',
auditOptions: { mdl_Users, parentAlias: 'TierPlan' },
findOptions: archived ? {
paranoid: false,
where: { deletedAt: { [Op.ne]: null } },
@@ -108,7 +112,7 @@ function computeDurationDays(value, unit) {
exports.createPlan = async (req, res) => {
try {
const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency } = req.body;
const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency, createdBy } = req.body;
if (!tier_category_id || !label || !duration_value || !price)
return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400);
@@ -125,6 +129,7 @@ exports.createPlan = async (req, res) => {
tier_category_id: category.tier_category_id,
tier: category.slug,
label, description, features, duration_days, duration_unit, price, currency,
createdBy: createdBy ?? req.user?.user_id ?? null,
});
const plain = plan.get({ plain: true });
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
@@ -145,6 +150,7 @@ exports.updatePlan = async (req, res) => {
for (const k of allowed) {
if (req.body[k] !== undefined) updates[k] = req.body[k];
}
updates.updatedBy = req.body.updatedBy ?? req.user?.user_id ?? null;
// Recompute duration_days when value or unit changes
const { duration_value, duration_unit } = req.body;
@@ -197,8 +203,23 @@ exports.archivePlan = async (req, res) => {
if (!plan) return R.error(res, 'Plan not found.', 404);
if (plan.deletedAt) return R.error(res, 'Plan is already archived.', 400);
await plan.update({ is_active: false });
await plan.update({ is_active: false, deletedBy: req.user?.user_id ?? null });
await plan.destroy();
try {
const userIds = await resolveTierPlanUserIds(plan.plan_id);
if (userIds.length) {
const now = new Date();
const notify = NOTIFICATION_REGISTRY.tier_plan_archived.build({ label: plan.label, planId: plan.plan_id });
await UserNotification.bulkCreate(
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
{ validate: false }
);
}
} catch (notifyErr) {
console.error('[ADMIN][ARCHIVE PLAN][NOTIFY]', notifyErr);
}
logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
return R.success(res, 'Plan archived successfully.');
} catch (err) {
@@ -222,9 +243,31 @@ exports.bulkArchivePlans = async (req, res) => {
const activeIds = activePlans.map((p) => p.plan_id);
await mdl_TierPlans.update({ is_active: false }, { where: { plan_id: activeIds } });
await mdl_TierPlans.update({ is_active: false, deletedBy: req.user?.user_id ?? null }, { where: { plan_id: activeIds } });
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
try {
const holders = await mdl_UserTiers.findAll({
attributes: ['user_id', 'plan_id'],
where: { plan_id: activeIds, status: 'active' },
raw: true,
});
if (holders.length) {
const now = new Date();
const labelByPlanId = new Map(activePlans.map((p) => [String(p.plan_id), p.label]));
const notifications = holders.map(({ user_id, plan_id }) => ({
user_id,
...NOTIFICATION_REGISTRY.tier_plan_archived.build({ label: labelByPlanId.get(String(plan_id)), planId: plan_id }),
seen: false,
createdAt: now,
updatedAt: now,
}));
await UserNotification.bulkCreate(notifications, { validate: false });
}
} catch (notifyErr) {
console.error('[ADMIN][BULK ARCHIVE PLANS][NOTIFY]', notifyErr);
}
logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} plan(s) archived successfully.`, {
archived_ids: activeIds,