client and some admin new

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-05 04:32:43 +08:00
parent 8922f7f2f4
commit 0c7f5ccd0f
33 changed files with 937 additions and 770 deletions
+38 -8
View File
@@ -102,14 +102,12 @@ async function applyAdvertisementFields(advertisement, body) {
// status is intentionally NOT settable here — it's derived via deriveStatus() // status is intentionally NOT settable here — it's derived via deriveStatus()
// right before save, based on is_active + start_date/end_date. // right before save, based on is_active + start_date/end_date.
if (body.content_mode !== undefined) { // content_mode is no longer an admin choice (the Image Only / Text with
if (!["image", "content"].includes(body.content_mode)) { // Image toggle was removed — every ad now carries the same mandatory
const err = new Error(`Invalid content_mode. Must be one of: image, content`); // badge/headline/description/image/link shape) — like type/format, it's
err.status = 400; // fixed server-side rather than trusted from the request body. Legacy
throw err; // "image" mode rows keep that value until next edited.
} advertisement.content_mode = "content";
advertisement.content_mode = body.content_mode;
}
if (body.badge_labels !== undefined) advertisement.badge_labels = normalizeBadgeLabels(body.badge_labels); if (body.badge_labels !== undefined) advertisement.badge_labels = normalizeBadgeLabels(body.badge_labels);
if (body.headline !== undefined) advertisement.headline = body.headline; if (body.headline !== undefined) advertisement.headline = body.headline;
@@ -149,6 +147,38 @@ async function applyAdvertisementFields(advertisement, body) {
advertisement.size = body.size || null; advertisement.size = body.size || null;
} }
// Every ad now carries the same mandatory shape — badge label(s), headline,
// description, image, and a single link — enforced here as defense-in-depth
// alongside the frontend's Zod schema. The Admin Add/Edit Advertisement
// forms always submit the full shape, so this only ever fires on malformed
// requests — it does not retroactively touch existing incomplete rows,
// it just blocks saving one until it's brought up to the new shape.
if (!advertisement.badge_labels?.length) {
const err = new Error("At least one badge label is required.");
err.status = 400;
throw err;
}
if (!advertisement.headline?.trim()) {
const err = new Error("Headline is required.");
err.status = 400;
throw err;
}
if (!advertisement.description?.trim()) {
const err = new Error("Description is required.");
err.status = 400;
throw err;
}
if (!advertisement.image_asset_id) {
const err = new Error("Image is required.");
err.status = 400;
throw err;
}
if (!advertisement.redirect_link?.trim()) {
const err = new Error("Link is required.");
err.status = 400;
throw err;
}
// Recompute status now that is_active/start_date/end_date are all up to date // Recompute status now that is_active/start_date/end_date are all up to date
advertisement.status = deriveStatus(advertisement); advertisement.status = deriveStatus(advertisement);
} }
+3 -21
View File
@@ -36,8 +36,6 @@ const {
const mdl_Users = require("../../models/users/users.mdl"); const mdl_Users = require("../../models/users/users.mdl");
const { mdl_PlanCourses, mdl_TierPlans } = require("../../models/tiers/tier.associations");
const { const {
excludeAttributes: courseExclude, excludeAttributes: courseExclude,
computedAttributes: courseComputed, computedAttributes: courseComputed,
@@ -2403,28 +2401,12 @@ exports.getCoursesBySubscription = async (req, res) => {
const rows = await Course.findAll({ const rows = await Course.findAll({
where: { ...notDeleted, subscription: slug }, where: { ...notDeleted, subscription: slug },
attributes: ['course_id', 'title', 'description', 'subscription'], attributes: ['course_id', 'title', 'description', 'subscription'],
include: [{
model: mdl_PlanCourses,
as: 'planCourse',
required: false,
attributes: ['plan_id'],
include: [{
model: mdl_TierPlans,
as: 'plan',
attributes: ['plan_id', 'label'],
}],
}],
order: [['title', 'ASC']], order: [['title', 'ASC']],
}); });
// Flatten so the frontend can just check `assigned_plan` — a course belongs // A course may belong to any number of other plans (Tier Plans v2, silent
// to at most one plan (UNIQUE constraint on plan_courses.course_id). // duplication across bundles is intentional) — no conflict to report here.
const data = rows.map((c) => { const data = rows.map((c) => c.toJSON());
const plain = c.toJSON();
const assigned_plan = plain.planCourse?.plan ?? null;
delete plain.planCourse;
return { ...plain, assigned_plan };
});
return R.success(res, 'Courses retrieved.', data); return R.success(res, 'Courses retrieved.', data);
} catch (err) { } catch (err) {
+3 -20
View File
@@ -37,7 +37,6 @@ const {
} = require("../../models/courses/courses.associations"); } = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl"); const mdl_Users = require("../../models/users/users.mdl");
const { mdl_PlanLessons, mdl_TierPlans } = require("../../models/tiers/tier.associations");
const notDeleted = { deletedAt: null }; const notDeleted = { deletedAt: null };
const onlyDeleted = { deletedAt: { [Op.not]: null } }; const onlyDeleted = { deletedAt: { [Op.not]: null } };
@@ -189,28 +188,12 @@ exports.getLessonsBySubscription = async (req, res) => {
const rows = await Lesson.findAll({ const rows = await Lesson.findAll({
where: { ...notDeleted, subscription: slug }, where: { ...notDeleted, subscription: slug },
attributes: ['lesson_id', 'title', 'description', 'subscription'], attributes: ['lesson_id', 'title', 'description', 'subscription'],
include: [{
model: mdl_PlanLessons,
as: 'planLesson',
required: false,
attributes: ['plan_id'],
include: [{
model: mdl_TierPlans,
as: 'plan',
attributes: ['plan_id', 'label'],
}],
}],
order: [['title', 'ASC']], order: [['title', 'ASC']],
}); });
// Flatten so the frontend can just check `assigned_plan` — a lesson // A lesson may belong to any number of other plans (Tier Plans v2, silent
// belongs to at most one plan (UNIQUE constraint on plan_lessons.lesson_id). // duplication across bundles is intentional) — no conflict to report here.
const data = rows.map((l) => { const data = rows.map((l) => l.toJSON());
const plain = l.toJSON();
const assigned_plan = plain.planLesson?.plan ?? null;
delete plain.planLesson;
return { ...plain, assigned_plan };
});
return R.success(res, 'Lessons retrieved.', data); return R.success(res, 'Lessons retrieved.', data);
} catch (err) { } catch (err) {
@@ -1,8 +1,6 @@
'use strict'; 'use strict';
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_PlanPolicies = require('../../models/tiers/plan_policies.mdl');
const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl'); const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl');
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
const Asset = require('../../models/assets/assets.mdl'); const Asset = require('../../models/assets/assets.mdl');
@@ -11,112 +9,8 @@ const logActivity = require('../../utils/logActivity.util');
require('../../models/tiers/tier.associations'); require('../../models/tiers/tier.associations');
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.';
// Load valid tier slugs dynamically from DB
const categories = await mdl_TierCategories.findAll({ attributes: ['slug'] });
const validSlugs = new Set(categories.map((c) => c.slug));
for (const rule of rules) {
if (!VALID_RULE_TYPES.has(rule.type)) return `Unknown rule type: ${rule.type}`;
if (rule.type === 'course_subscription_access') {
if (!Array.isArray(rule.levels) || !rule.levels.length)
return 'course_subscription_access.levels must be a non-empty array of tier slugs.';
}
if (rule.type === 'required_active_tier') {
if (!rule.tier || !validSlugs.has(rule.tier))
return `required_active_tier.tier must be a valid tier category slug.`;
}
if (rule.type === 'group_restriction') {
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;
}
const ASSET_ATTRS = ['asset_id', 'file_url', 'display_name', 'mime_type']; const ASSET_ATTRS = ['asset_id', 'file_url', 'display_name', 'mime_type'];
// ─── PLAN POLICIES ────────────────────────────────────────────────────────────
exports.getPlanPolicy = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.planId);
if (!plan) return R.error(res, 'Plan not found.', 404);
const policy = await mdl_PlanPolicies.findOne({ where: { plan_id: req.params.planId } });
return R.success(res, 'Policy retrieved.', policy ?? null);
} catch (err) {
console.error('[ADMIN][GET PLAN POLICY]', err);
return R.error(res, 'Could not retrieve policy.', 500);
}
};
exports.upsertPlanPolicy = async (req, res) => {
try {
const { planId } = req.params;
const plan = await mdl_TierPlans.findByPk(planId);
if (!plan) return R.error(res, 'Plan not found.', 404);
const { access_rules } = req.body;
let parsedRules = [];
if (access_rules !== undefined) {
try {
parsedRules = typeof access_rules === 'string' ? JSON.parse(access_rules) : access_rules;
} catch {
return R.error(res, 'access_rules is not valid JSON.', 400);
}
const err = await validateRules(parsedRules);
if (err) return R.error(res, err, 400);
}
let existing = await mdl_PlanPolicies.findOne({ where: { plan_id: planId } });
const payload = {
plan_id: planId,
access_rules: access_rules !== undefined ? parsedRules : (existing?.access_rules ?? []),
};
if (!existing) {
existing = await mdl_PlanPolicies.create(payload);
logActivity(req.user?.user_id, 'create_plan_policy', { entityType: 'plan_policy', details: { plan_id: planId } });
} else {
await existing.update(payload);
logActivity(req.user?.user_id, 'update_plan_policy', { entityType: 'plan_policy', details: { plan_id: planId } });
}
const result = await mdl_PlanPolicies.findOne({ where: { plan_id: planId } });
return R.success(res, 'Policy saved.', result);
} catch (err) {
console.error('[ADMIN][UPSERT PLAN POLICY]', err);
return R.error(res, 'Could not save policy.', 500);
}
};
// ─── PAYMENT POLICIES ───────────────────────────────────────────────────────── // ─── PAYMENT POLICIES ─────────────────────────────────────────────────────────
const VALID_PROMO_TYPES = new Set(['flat', 'percent']); const VALID_PROMO_TYPES = new Set(['flat', 'percent']);
+74 -54
View File
@@ -28,9 +28,8 @@ const R = require('../../utils/response.util');
const { paginate } = require('../../utils/paginate.util'); const { paginate } = require('../../utils/paginate.util');
const { getFieldValues } = require('../../utils/fieldValues.util'); const { getFieldValues } = require('../../utils/fieldValues.util');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const { snapshotPlanGrants } = require('../../services/tierGrants.service');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { revokePlanSubscriberAccess } = require('../../services/planAccess.service');
const { resolveTierPlanUserIds } = require('../../utils/audienceResolver.util');
const { const {
excludeAttributes: plansExclude, excludeAttributes: plansExclude,
@@ -112,7 +111,7 @@ function computeDurationDays(value, unit) {
exports.createPlan = async (req, res) => { exports.createPlan = async (req, res) => {
try { try {
const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency, createdBy } = req.body; const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency, createdBy, status } = req.body;
if (!tier_category_id || !label || !duration_value || !price) if (!tier_category_id || !label || !duration_value || !price)
return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400); return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400);
@@ -129,6 +128,7 @@ exports.createPlan = async (req, res) => {
tier_category_id: category.tier_category_id, tier_category_id: category.tier_category_id,
tier: category.slug, tier: category.slug,
label, description, features, duration_days, duration_unit, price, currency, label, description, features, duration_days, duration_unit, price, currency,
status: status ?? 'draft',
createdBy: createdBy ?? req.user?.user_id ?? null, createdBy: createdBy ?? req.user?.user_id ?? null,
}); });
const plain = plan.get({ plain: true }); const plain = plan.get({ plain: true });
@@ -145,7 +145,7 @@ exports.updatePlan = async (req, res) => {
const plan = await mdl_TierPlans.findByPk(req.params.id); const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404); if (!plan) return R.error(res, 'Plan not found.', 404);
const allowed = ['label', 'description', 'features', 'price', 'currency', 'is_active', 'tier_category_id']; const allowed = ['label', 'description', 'features', 'price', 'currency', 'is_active', 'status', 'tier_category_id'];
const updates = {}; const updates = {};
for (const k of allowed) { for (const k of allowed) {
if (req.body[k] !== undefined) updates[k] = req.body[k]; if (req.body[k] !== undefined) updates[k] = req.body[k];
@@ -206,22 +206,19 @@ exports.archivePlan = async (req, res) => {
await plan.update({ is_active: false, deletedBy: req.user?.user_id ?? null }); await plan.update({ is_active: false, deletedBy: req.user?.user_id ?? null });
await plan.destroy(); await plan.destroy();
// Archiving always force-revokes current subscribers' access (no refund) —
// fires tier_plan_access_revoked (see revokePlanSubscriberAccess), not the
// old "access unaffected" tier_plan_archived notice, since that's no
// longer true.
let revoked_user_count = 0;
try { try {
const userIds = await resolveTierPlanUserIds(plan.plan_id); ({ revoked_user_count } = await revokePlanSubscriberAccess(plan, req.user?.user_id ?? null));
if (userIds.length) { } catch (revokeErr) {
const now = new Date(); console.error('[ADMIN][ARCHIVE PLAN][REVOKE ACCESS]', revokeErr);
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 } }); logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label, revoked_user_count } });
return R.success(res, 'Plan archived successfully.'); return R.success(res, 'Plan archived successfully.', { revoked_user_count });
} catch (err) { } catch (err) {
console.error('[ADMIN][ARCHIVE PLAN]', err); console.error('[ADMIN][ARCHIVE PLAN]', err);
return R.error(res, 'Could not archive plan.', 500); return R.error(res, 'Could not archive plan.', 500);
@@ -246,32 +243,24 @@ exports.bulkArchivePlans = async (req, res) => {
await mdl_TierPlans.update({ is_active: false, deletedBy: req.user?.user_id ?? null }, { 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 } }); await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
try { // Archiving always force-revokes current subscribers' access (no refund) —
const holders = await mdl_UserTiers.findAll({ // each plan fires its own tier_plan_access_revoked (needs each plan's own
attributes: ['user_id', 'plan_id'], // label), not the old batched "access unaffected" tier_plan_archived notice.
where: { plan_id: activeIds, status: 'active' }, let revoked_user_count = 0;
raw: true, for (const p of activePlans) {
}); try {
if (holders.length) { const { revoked_user_count: c } = await revokePlanSubscriberAccess(p, req.user?.user_id ?? null);
const now = new Date(); revoked_user_count += c;
const labelByPlanId = new Map(activePlans.map((p) => [String(p.plan_id), p.label])); } catch (revokeErr) {
const notifications = holders.map(({ user_id, plan_id }) => ({ console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr);
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 } }); logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length, revoked_user_count } });
return R.success(res, `${activeIds.length} plan(s) archived successfully.`, { return R.success(res, `${activeIds.length} plan(s) archived successfully.`, {
archived_ids: activeIds, archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)), skipped_ids: ids.filter((id) => !activeIds.includes(id)),
revoked_user_count,
}); });
} catch (err) { } catch (err) {
console.error('[ADMIN][BULK ARCHIVE PLANS]', err); console.error('[ADMIN][BULK ARCHIVE PLANS]', err);
@@ -351,12 +340,24 @@ exports.permanentlyDeletePlan = async (req, res) => {
if (!plan) return R.error(res, 'Plan not found.', 404); if (!plan) return R.error(res, 'Plan not found.', 404);
if (!plan.deletedAt) return R.error(res, 'Plan must be archived before it can be permanently deleted.', 400); if (!plan.deletedAt) return R.error(res, 'Plan must be archived before it can be permanently deleted.', 400);
// A plan may still have active subscribers if it was archived before the
// auto-revoke-on-archive behavior existed, or if revoking failed the
// first time — permanently deleting it must not leave them with orphaned
// access (user_tiers.plan_id would just go NULL on delete, not revoke).
const { revoked_user_count } = await revokePlanSubscriberAccess(plan, req.user?.user_id ?? null);
// payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted
// by default) — a plan can't be force-destroyed while payment rows still
// reference it, so those rows are force-destroyed first. This permanently
// erases that plan's payment/billing history; there is no undo.
const deleted_payment_count = await mdl_Payments.destroy({ where: { plan_id: plan.plan_id }, force: true });
await plan.destroy({ force: true }); await plan.destroy({ force: true });
logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label, deleted_payment_count, revoked_user_count } });
return R.success(res, 'Plan permanently deleted.'); return R.success(res, 'Plan permanently deleted.', { deleted_payment_count, revoked_user_count });
} catch (err) { } catch (err) {
if (err instanceof ForeignKeyConstraintError) { if (err instanceof ForeignKeyConstraintError) {
return R.error(res, 'Cannot delete: this plan still has payment records on file. Payment history is preserved and cannot be removed.', 400); return R.error(res, 'Cannot delete: this plan still has records on file referencing it.', 400);
} }
console.error('[ADMIN][PERMANENT DELETE PLAN]', err); console.error('[ADMIN][PERMANENT DELETE PLAN]', err);
return R.error(res, 'Could not permanently delete plan.', 500); return R.error(res, 'Could not permanently delete plan.', 500);
@@ -378,16 +379,33 @@ exports.bulkPermanentlyDeletePlans = async (req, res) => {
const archivedIds = archivedPlans.map((p) => p.plan_id); const archivedIds = archivedPlans.map((p) => p.plan_id);
// Same reasoning as the single-delete path above: revoke any remaining
// active subscribers per plan (each needs its own label for the
// notification/email) before the records are gone for good.
let revoked_user_count = 0;
for (const p of archivedPlans) {
const { revoked_user_count: c } = await revokePlanSubscriberAccess(p, req.user?.user_id ?? null);
revoked_user_count += c;
}
// payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted
// by default) — plans can't be force-destroyed while payment rows still
// reference them, so those rows are force-destroyed first. This permanently
// erases these plans' payment/billing history; there is no undo.
const deleted_payment_count = await mdl_Payments.destroy({ where: { plan_id: archivedIds }, force: true });
await mdl_TierPlans.destroy({ where: { plan_id: archivedIds }, force: true }); await mdl_TierPlans.destroy({ where: { plan_id: archivedIds }, force: true });
logActivity(req.user?.user_id, 'bulk_permanently_delete_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length } }); logActivity(req.user?.user_id, 'bulk_permanently_delete_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length, deleted_payment_count, revoked_user_count } });
return R.success(res, `${archivedIds.length} plan(s) permanently deleted.`, { return R.success(res, `${archivedIds.length} plan(s) permanently deleted.`, {
deleted_ids: archivedIds, deleted_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)), skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
deleted_payment_count,
revoked_user_count,
}); });
} catch (err) { } catch (err) {
if (err instanceof ForeignKeyConstraintError) { if (err instanceof ForeignKeyConstraintError) {
return R.error(res, 'Cannot delete: one or more selected plans still have payment records on file. Payment history is preserved and cannot be removed.', 400); return R.error(res, 'Cannot delete: one or more selected plans still have records on file referencing them.', 400);
} }
console.error('[ADMIN][BULK PERMANENT DELETE PLANS]', err); console.error('[ADMIN][BULK PERMANENT DELETE PLANS]', err);
return R.error(res, 'Could not permanently delete plans.', 500); return R.error(res, 'Could not permanently delete plans.', 500);
@@ -430,11 +448,14 @@ exports.grantTier = async (req, res) => {
if (!plan || !plan.is_active) return R.error(res, 'Plan not found or inactive.', 404); if (!plan || !plan.is_active) return R.error(res, 'Plan not found or inactive.', 404);
const tier = plan.tier; const tier = plan.tier;
// Keyed on plan_id, not tier — a user may already hold a different plan
// at this same tier slug (Tier Plans v2 allows multiple concurrently
// active plans per tier); only re-granting the exact same plan is blocked.
const existingActive = await mdl_UserTiers.findOne({ const existingActive = await mdl_UserTiers.findOne({
where: { user_id, tier, status: 'active' }, where: { user_id, plan_id, status: 'active' },
}); });
if (existingActive) { if (existingActive) {
return R.error(res, `User already has an active ${tier} subscription until ${existingActive.expires_at}.`, 409); return R.error(res, `User already has this plan active until ${existingActive.expires_at}.`, 409);
} }
const startsAt = new Date(); const startsAt = new Date();
@@ -448,6 +469,8 @@ exports.grantTier = async (req, res) => {
notes, notes,
}); });
await snapshotPlanGrants(newTier, plan_id);
logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } }); logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } });
return R.success(res, 'Tier granted.', newTier, 201); return R.success(res, 'Tier granted.', newTier, 201);
} catch (err) { } catch (err) {
@@ -578,9 +601,8 @@ exports.syncPlanCourses = async (req, res) => {
await mdl_PlanCourses.destroy({ where: { plan_id: id } }); await mdl_PlanCourses.destroy({ where: { plan_id: id } });
if (course_ids.length) { if (course_ids.length) {
// course_id has a UNIQUE constraint — clear any orphaned/other-plan assignments // A course may already belong to other plans — that's allowed (Tier Plans v2,
// for these courses before inserting, so the insert isn't silently skipped // silent duplication across bundles), so we only ever touch this plan's own rows.
await mdl_PlanCourses.destroy({ where: { course_id: course_ids } });
await mdl_PlanCourses.bulkCreate( await mdl_PlanCourses.bulkCreate(
course_ids.map(course_id => ({ plan_id: id, course_id })), course_ids.map(course_id => ({ plan_id: id, course_id })),
); );
@@ -630,9 +652,8 @@ exports.syncPlanUnits = async (req, res) => {
await mdl_PlanUnits.destroy({ where: { plan_id: id } }); await mdl_PlanUnits.destroy({ where: { plan_id: id } });
if (unit_ids.length) { if (unit_ids.length) {
// unit_id has a UNIQUE constraint — clear any orphaned/other-plan assignments // A unit may already belong to other plans — that's allowed (Tier Plans v2,
// for these units before inserting, so the insert isn't silently skipped // silent duplication across bundles), so we only ever touch this plan's own rows.
await mdl_PlanUnits.destroy({ where: { unit_id: unit_ids } });
await mdl_PlanUnits.bulkCreate( await mdl_PlanUnits.bulkCreate(
unit_ids.map(unit_id => ({ plan_id: id, unit_id })), unit_ids.map(unit_id => ({ plan_id: id, unit_id })),
); );
@@ -682,9 +703,8 @@ exports.syncPlanLessons = async (req, res) => {
await mdl_PlanLessons.destroy({ where: { plan_id: id } }); await mdl_PlanLessons.destroy({ where: { plan_id: id } });
if (lesson_ids.length) { if (lesson_ids.length) {
// lesson_id has a UNIQUE constraint — clear any orphaned/other-plan assignments // A lesson may already belong to other plans — that's allowed (Tier Plans v2,
// for these lessons before inserting, so the insert isn't silently skipped // silent duplication across bundles), so we only ever touch this plan's own rows.
await mdl_PlanLessons.destroy({ where: { lesson_id: lesson_ids } });
await mdl_PlanLessons.bulkCreate( await mdl_PlanLessons.bulkCreate(
lesson_ids.map(lesson_id => ({ plan_id: id, lesson_id })), lesson_ids.map(lesson_id => ({ plan_id: id, lesson_id })),
); );
+3 -20
View File
@@ -41,7 +41,6 @@ const CompletionRequirement = require("../../models/courses/completion_requireme
const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry"); const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry");
const mdl_Users = require("../../models/users/users.mdl"); const mdl_Users = require("../../models/users/users.mdl");
const { mdl_PlanUnits, mdl_TierPlans } = require("../../models/tiers/tier.associations");
const notDeleted = { deletedAt: null }; const notDeleted = { deletedAt: null };
const onlyDeleted = { deletedAt: { [Op.not]: null } }; const onlyDeleted = { deletedAt: { [Op.not]: null } };
@@ -192,28 +191,12 @@ exports.getUnitsBySubscription = async (req, res) => {
const rows = await Unit.findAll({ const rows = await Unit.findAll({
where: { ...notDeleted, subscription: slug }, where: { ...notDeleted, subscription: slug },
attributes: ['unit_id', 'title', 'description', 'subscription'], attributes: ['unit_id', 'title', 'description', 'subscription'],
include: [{
model: mdl_PlanUnits,
as: 'planUnit',
required: false,
attributes: ['plan_id'],
include: [{
model: mdl_TierPlans,
as: 'plan',
attributes: ['plan_id', 'label'],
}],
}],
order: [['title', 'ASC']], order: [['title', 'ASC']],
}); });
// Flatten so the frontend can just check `assigned_plan` — a unit belongs // A unit may belong to any number of other plans (Tier Plans v2, silent
// to at most one plan (UNIQUE constraint on plan_units.unit_id). // duplication across bundles is intentional) — no conflict to report here.
const data = rows.map((u) => { const data = rows.map((u) => u.toJSON());
const plain = u.toJSON();
const assigned_plan = plain.planUnit?.plan ?? null;
delete plain.planUnit;
return { ...plain, assigned_plan };
});
return R.success(res, 'Units retrieved.', data); return R.success(res, 'Units retrieved.', data);
} catch (err) { } catch (err) {
@@ -4,7 +4,6 @@ const mdl_Product = require('../../models/courses/products.mdl');
const paymentSvc = require('../../services/payment.service'); const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util'); const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util');
const { isPurchaseEligible } = require('./courses.controller');
// ─── CREATE ORDER ───────────────────────────────────────────────────────────── // ─── CREATE ORDER ─────────────────────────────────────────────────────────────
// Despite the "course" naming (historical — this predates Units/Lessons being // Despite the "course" naming (historical — this predates Units/Lessons being
@@ -30,11 +29,6 @@ exports.createCourseOrder = async (req, res) => {
const target = await resolvePurchasable(product.purchasable_type, product.purchasable_id); const target = await resolvePurchasable(product.purchasable_type, product.purchasable_id);
if (!target) return R.error(res, 'Purchasable content not found.', 404); if (!target) return R.error(res, 'Purchasable content not found.', 404);
const eligible = await isPurchaseEligible(req.user.user_id, target.subscription, product.purchasable_type, product.purchasable_id);
if (!eligible) {
return R.error(res, "Complete your plan's starter content before purchasing more.", 403);
}
const path = checkoutPath(product.purchasable_type, target); const path = checkoutPath(product.purchasable_type, target);
const ppOrder = await paymentSvc.createOrder('paypal', { const ppOrder = await paymentSvc.createOrder('paypal', {
+83 -178
View File
@@ -4,9 +4,8 @@
* Description: User-facing course endpoints (read-only). * Description: User-facing course endpoints (read-only).
* Access rules: * Access rules:
* - All courses are returned in the list (for upsell visibility) * - All courses are returned in the list (for upsell visibility)
* - Each course has is_locked: boolean based on the user's active tier * - Each course has is_locked: boolean based on item-specific entitlement
* - free / no active tier → unassigned courses are open; plan courses are locked * (user_tier_grants — see hasItemGrant) or an individual purchase
* - premium (active tier) → unassigned + courses under their plan are open
* - getCourse still enforces hard 403 on locked access * - getCourse still enforces hard 403 on locked access
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 7, 2026 * Date Created: Jun. 7, 2026
@@ -16,12 +15,10 @@
const { Op } = require("sequelize"); const { Op } = require("sequelize");
const R = require("../../utils/response.util"); const R = require("../../utils/response.util");
const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl"); const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl");
const { mdl_UserTierGrants } = require("../../models/tiers/tier.associations");
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl"); const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const mdl_Product = require("../../models/courses/products.mdl"); const mdl_Product = require("../../models/courses/products.mdl");
const mdl_Category = require("../../models/courses/categories.mdl"); const mdl_Category = require("../../models/courses/categories.mdl");
const mdl_PlanPolicy = require("../../models/tiers/plan_policies.mdl");
const { mdl_UserGroupMembers } = require("../../models/users/user_groups.mdl");
const { evaluateCourseAccess } = require("../../utils/accessPolicy.util");
const { const {
Course, Course,
@@ -34,7 +31,6 @@ const {
} = require("../../models/courses/courses.associations"); } = require("../../models/courses/courses.associations");
const { flattenUnits, flattenLessons } = require("../../utils/courses/hierarchy.util"); const { flattenUnits, flattenLessons } = require("../../utils/courses/hierarchy.util");
const { resolvePrerequisiteTitles, resolvePrerequisiteCompletion } = require("../../utils/courses/resolvePrerequisiteTitles.util"); const { resolvePrerequisiteTitles, resolvePrerequisiteCompletion } = require("../../utils/courses/resolvePrerequisiteTitles.util");
const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl");
const { gradeSubmission } = require("../../utils/courses/grading.util"); const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util"); const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
const { onCourseCompleted } = require('../../services/achievements.service'); const { onCourseCompleted } = require('../../services/achievements.service');
@@ -90,43 +86,6 @@ async function expireSession(session, passingScore) {
return expiredAttempt; return expiredAttempt;
} }
// Builds user context for evaluateCourseAccess: the user's best (highest-rank)
// active tier slug, live tier rank map, one ruleset per concurrently-active plan
// that has access_rules configured, and group memberships (needed for the
// group_restriction rule type). A user can hold more than one active tier at
// once (e.g. premium + exclusive), so "tier" here is the effective best one for
// plain rank checks, while "rulesets" preserves each active plan's own rules
// for the OR-across-active-plans check in canAccessCourse.
async function buildUserContext(user_id) {
const activeTiers = await getActiveTiers(user_id);
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
const tierRankMap = {};
for (const c of categories) tierRankMap[c.slug] = c.rank;
let tier = 'free';
let bestRank = -Infinity;
for (const t of activeTiers) {
const rank = tierRankMap[t.tier] ?? 0;
if (rank > bestRank) { bestRank = rank; tier = t.tier; }
}
const rulesets = [];
for (const t of activeTiers) {
if (!t.plan_id) continue;
const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: t.plan_id } });
if (policy?.access_rules?.length) rulesets.push({ tier: t.tier, access_rules: policy.access_rules });
}
const memberships = await mdl_UserGroupMembers.findAll({
where: { user_id, deletedAt: null },
attributes: ['group_id'],
});
const group_ids = memberships.map((m) => m.group_id);
return { tier, tierRankMap, rulesets, group_ids, activeTiers };
}
// Individual-purchase check shared by all three content types — a Product is // Individual-purchase check shared by all three content types — a Product is
// keyed by (purchasable_type, purchasable_id), see utils/purchasable.util.js. // keyed by (purchasable_type, purchasable_id), see utils/purchasable.util.js.
async function hasActivePurchase(user_id, purchasable_type, purchasable_id) { async function hasActivePurchase(user_id, purchasable_type, purchasable_id) {
@@ -144,35 +103,39 @@ async function hasActivePurchase(user_id, purchasable_type, purchasable_id) {
return !!hasPurchase; return !!hasPurchase;
} }
// ─── Item-specific entitlement check (Tier Plans v2) ─────────────────────────
// A Tier Plan purchase snapshots the exact items its bundle granted into
// user_tier_grants at purchase time (see captureOrder in
// controllers/client/tiers.controller.js) — this replaces the old tier-rank
// comparison, which unlocked ALL same-level content off any active purchase.
async function hasItemGrant(user_id, item_type, item_id) {
const grant = await mdl_UserTierGrants.findOne({
where: { user_id, item_type, item_id },
include: [{
model: mdl_UserTiers,
as: 'userTier',
attributes: [],
where: { status: 'active' },
required: true,
}],
});
return !!grant;
}
// ─── Shared tier + purchase access check ───────────────────────────────────── // ─── Shared tier + purchase access check ─────────────────────────────────────
// Returns true → user may access the course. // Returns true → user may access the course.
// Returns false → user's tier is too low AND no valid individual purchase. // Returns false → user has no grant for this exact course AND no valid individual purchase.
async function canAccessCourse(user_id, course_id) { async function canAccessCourse(user_id, course_id) {
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription', 'status'] }); const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription', 'status'] });
if (!course) return false; if (!course) return false;
if (course.status !== 'published') return false; if (course.status !== 'published') return false;
const userCtx = await buildUserContext(user_id); // Free/ungated content is always accessible.
if (!course.subscription || course.subscription === 'free') return true;
// Plain rank check against the user's best active tier — evaluateCourseAccess // Item-specific entitlement: did any purchased Tier Plan bundle grant THIS
// falls back to rank comparison when access_rules is empty. Same behavior as // exact course?
// before for every course/plan combination that hasn't opted into the richer if (await hasItemGrant(user_id, 'course', course_id)) return true;
// rule engine.
const { allowed: rankAllowed } = evaluateCourseAccess(
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
course, userCtx.tierRankMap, { type: 'course', id: course_id }
);
if (rankAllowed) return true;
// Rule-based: a user can hold more than one active plan concurrently, and each
// one's access_rules is independent — access is granted if ANY of them allow it.
for (const ruleset of userCtx.rulesets) {
const { allowed } = evaluateCourseAccess(
{ tier: ruleset.tier, access_rules: ruleset.access_rules, group_ids: userCtx.group_ids },
course, userCtx.tierRankMap, { type: 'course', id: course_id }
);
if (allowed) return true;
}
// Individual purchase as fallback // Individual purchase as fallback
return hasActivePurchase(user_id, 'course', course_id); return hasActivePurchase(user_id, 'course', course_id);
@@ -187,30 +150,13 @@ async function canAccessCourse(user_id, course_id) {
// genuinely standalone content run independently. // genuinely standalone content run independently.
async function canAccessUnit(user_id, unit_id) { async function canAccessUnit(user_id, unit_id) {
// A unit's own subscription (standalone tier-gating) is an additional, // A unit's own grant (item-specific — a Unit bundle purchase grants only
// OR'd access path alongside any attached course's access — most // this unit, not its parent course) is an additional, OR'd access path
// standalone units have zero course links anyway, but a unit that somehow // alongside any attached course's access — most standalone units have zero
// has both should be unlockable via either. // course links anyway, but a unit that somehow has both should be
// unlockable via either.
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] }); const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] });
if (unit?.subscription) { if (unit?.subscription && await hasItemGrant(user_id, 'unit', unit_id)) return true;
const userCtx = await buildUserContext(user_id);
// Plain rank check first, same baseline as canAccessCourse.
const { allowed: rankAllowed } = evaluateCourseAccess(
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
{ subscription: unit.subscription }, userCtx.tierRankMap, { type: 'unit', id: unit_id }
);
if (rankAllowed) return true;
// Rule-based fallback (item_allowlist previews, etc.) — mirrors
// canAccessCourse's OR-across-active-plans check, previously missing here.
for (const ruleset of userCtx.rulesets) {
const { allowed } = evaluateCourseAccess(
{ tier: ruleset.tier, access_rules: ruleset.access_rules, group_ids: userCtx.group_ids },
{ subscription: unit.subscription }, userCtx.tierRankMap, { type: 'unit', id: unit_id }
);
if (allowed) return true;
}
}
if (await hasActivePurchase(user_id, 'unit', unit_id)) return true; if (await hasActivePurchase(user_id, 'unit', unit_id)) return true;
@@ -231,25 +177,10 @@ async function canAccessUnit(user_id, unit_id) {
} }
async function canAccessLesson(user_id, lesson_id) { async function canAccessLesson(user_id, lesson_id) {
// Mirrors canAccessUnit's shape: own subscription, then own purchase, then // Mirrors canAccessUnit's shape: own grant, then own purchase, then fall
// fall through to attached units (OR'd — a lesson can sit in more than one). // through to attached units (OR'd — a lesson can sit in more than one).
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] }); const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
if (lesson?.subscription) { if (lesson?.subscription && await hasItemGrant(user_id, 'lesson', lesson_id)) return true;
const userCtx = await buildUserContext(user_id);
const { allowed: rankAllowed } = evaluateCourseAccess(
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
{ subscription: lesson.subscription }, userCtx.tierRankMap, { type: 'lesson', id: lesson_id }
);
if (rankAllowed) return true;
for (const ruleset of userCtx.rulesets) {
const { allowed } = evaluateCourseAccess(
{ tier: ruleset.tier, access_rules: ruleset.access_rules, group_ids: userCtx.group_ids },
{ subscription: lesson.subscription }, userCtx.tierRankMap, { type: 'lesson', id: lesson_id }
);
if (allowed) return true;
}
}
if (await hasActivePurchase(user_id, 'lesson', lesson_id)) return true; if (await hasActivePurchase(user_id, 'lesson', lesson_id)) return true;
@@ -261,58 +192,9 @@ async function canAccessLesson(user_id, lesson_id) {
return false; return false;
} }
// ─── Starter-set completion → paid-unlock eligibility ─────────────────────────
// An `item_allowlist` access rule on a plan whose OWN tier matches the item
// being evaluated doubles as a "starter set": those items are the plan's free
// included content, and the subscriber can only purchase ADDITIONAL individual
// items at that same level once every starter item is complete. A plan whose
// tier doesn't match (e.g. a Premium plan previewing a couple of Exclusive
// items) is a preview grant only — it never gates purchase eligibility.
async function areAllItemsComplete(user_id, item_type, item_ids) {
if (!Array.isArray(item_ids) || !item_ids.length) return false;
for (const id of item_ids) {
const result = await evaluateEntity({
entityType: item_type,
entityId: id,
userId: user_id,
courseId: item_type === 'course' ? id : null,
});
if (result.status !== 'completed') return false;
}
return true;
}
async function isPurchaseEligible(user_id, itemSubscription, itemType, itemId) {
if (!itemSubscription || itemSubscription === 'free') return true;
const activeTiers = await getActiveTiers(user_id);
const relevantPlanIds = activeTiers
.filter((t) => t.tier === itemSubscription && t.plan_id)
.map((t) => t.plan_id);
if (!relevantPlanIds.length) return true; // not subscribed at this level — unrestricted, unchanged behavior
for (const planId of relevantPlanIds) {
const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: planId } });
const allowlistRules = (policy?.access_rules ?? []).filter((r) => r.type === 'item_allowlist');
if (!allowlistRules.length) return true; // this active plan imposes no starter-set gate
for (const rule of allowlistRules) {
if (rule.item_type === itemType && (rule.item_ids ?? []).map(String).includes(String(itemId))) {
return true; // item itself is in the free starter set — no purchase needed
}
if (await areAllItemsComplete(user_id, rule.item_type, rule.item_ids)) {
return true; // starter set complete — this plan unlocks paid access to more
}
}
}
return false;
}
exports.canAccessCourse = canAccessCourse; exports.canAccessCourse = canAccessCourse;
exports.canAccessUnit = canAccessUnit; exports.canAccessUnit = canAccessUnit;
exports.canAccessLesson = canAccessLesson; exports.canAccessLesson = canAccessLesson;
exports.isPurchaseEligible = isPurchaseEligible;
const COURSE_LIST_ATTRS = [ const COURSE_LIST_ATTRS = [
"course_id", "uuid", "title", "description", "course_id", "uuid", "title", "description",
@@ -336,15 +218,6 @@ function sanitizeQuestions(questions = []) {
}); });
} }
// Resolve ALL of the caller's concurrently-active tiers (empty array if free/expired) —
// a user can hold more than one active tier at once (e.g. premium + exclusive).
async function getActiveTiers(user_id) {
return mdl_UserTiers.findAll({
where: { user_id, status: "active" },
order: [["createdAt", "DESC"]],
});
}
// ─── COURSE CATEGORIES (public list for filter chips) ───────────────────────── // ─── COURSE CATEGORIES (public list for filter chips) ─────────────────────────
exports.getCategories = async (req, res) => { exports.getCategories = async (req, res) => {
@@ -367,9 +240,6 @@ exports.getCourses = async (req, res) => {
try { try {
const { category } = req.query; // optional slug filter const { category } = req.query; // optional slug filter
const userCtx = await buildUserContext(req.user.user_id);
const userTier = userCtx.tier;
// Fetch all completed purchases for this user (for has_purchased check) — // Fetch all completed purchases for this user (for has_purchased check) —
// course_purchases now spans all three content types, so filter down to // course_purchases now spans all three content types, so filter down to
// course-targeted products here. // course-targeted products here.
@@ -385,6 +255,16 @@ exports.getCourses = async (req, res) => {
.map((p) => String(p.product.purchasable_id)) .map((p) => String(p.product.purchasable_id))
); );
// Item-specific entitlement (Tier Plans v2) — batch-fetch every course this
// user was granted by an active Tier Plan purchase, same source canAccessCourse
// checks per-item via hasItemGrant.
const myGrants = await mdl_UserTierGrants.findAll({
where: { user_id: req.user.user_id, item_type: 'course' },
include: [{ model: mdl_UserTiers, as: 'userTier', attributes: [], where: { status: 'active' }, required: true }],
attributes: ['item_id'],
});
const grantedCourseIds = new Set(myGrants.map((g) => String(g.item_id)));
// Build category filter // Build category filter
const categoryInclude = { const categoryInclude = {
model: mdl_Category, model: mdl_Category,
@@ -411,15 +291,13 @@ exports.getCourses = async (req, res) => {
order: [['order_index', 'ASC'], ['title', 'ASC']], order: [['order_index', 'ASC'], ['title', 'ASC']],
}); });
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
const result = courses.map((c) => { const result = courses.map((c) => {
const plain = c.toJSON(); const plain = c.toJSON();
const has_purchased = purchasedCourseIds.has(String(plain.course_id)); const has_purchased = purchasedCourseIds.has(String(plain.course_id));
const has_grant = grantedCourseIds.has(String(plain.course_id));
const subscription = plain.subscription ?? 'free'; const subscription = plain.subscription ?? 'free';
const courseRank = userCtx.tierRankMap[subscription] ?? Infinity;
const is_locked = courseRank > 0 && !has_purchased && userRank < courseRank; const is_locked = subscription !== 'free' && !has_purchased && !has_grant;
return { ...plain, is_locked, has_purchased }; return { ...plain, is_locked, has_purchased };
}); });
@@ -573,6 +451,38 @@ exports.getCourse = async (req, res) => {
})); }));
} }
// Same idea, one level up — each unit's own completion trigger (read_all_content
// [default] / pass_quiz / manual_complete) plus the course's, so UnitList.jsx can
// show a "how to complete this unit / this course" explainer without another
// round trip (mirrors getLessonsByUnitUuid's standalone-reader equivalent).
const allUnitIds = plain.units?.map((u) => u.unit_id) ?? [];
if (allUnitIds.length) {
const unitRequirements = await CompletionRequirement.findAll({
where: { entity_type: 'unit', entity_id: allUnitIds },
attributes: ['entity_id', 'type', 'min_percent', 'button_label'],
});
const byUnitId = new Map();
unitRequirements.forEach((r) => { if (!byUnitId.has(String(r.entity_id))) byUnitId.set(String(r.entity_id), r); });
plain.units = plain.units.map((u) => {
const row = byUnitId.get(String(u.unit_id));
return {
...u,
completion: row
? { type: row.type, min_percent: row.min_percent, button_label: row.button_label }
: { type: 'read_all_content', min_percent: null, button_label: null },
};
});
}
const courseRequirement = await CompletionRequirement.findOne({
where: { entity_type: 'course', entity_id: course.course_id },
attributes: ['type', 'min_percent', 'button_label'],
});
plain.completion = courseRequirement
? { type: courseRequirement.type, min_percent: courseRequirement.min_percent, button_label: courseRequirement.button_label }
: { type: 'read_all_content', min_percent: null, button_label: null };
// has_passed reflects the assessment attempt alone; is_completed is the consolidated // has_passed reflects the assessment attempt alone; is_completed is the consolidated
// evaluator's result (default rule: all units read AND assessment passed, if one exists — // evaluator's result (default rule: all units read AND assessment passed, if one exists —
// was previously hardcoded to assessment-pass alone here too, same bug fixed in // was previously hardcoded to assessment-pass alone here too, same bug fixed in
@@ -607,9 +517,7 @@ exports.getCourse = async (req, res) => {
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
}, },
}); });
const purchaseEligible = (!product || hasPurchase) const purchaseEligible = true;
? true
: await isPurchaseEligible(req.user.user_id, plain.subscription, 'course', courseId);
// Certificate status for the course details card // Certificate status for the course details card
const [pendingCert, certificate] = await Promise.all([ const [pendingCert, certificate] = await Promise.all([
@@ -1649,10 +1557,7 @@ async function buildCheckoutInfo(user_id, purchasable_type, record) {
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
}, },
}); });
const purchaseEligible = (!product || hasPurchase) return { product: product ?? null, has_purchased: !!hasPurchase, purchase_eligible: true };
? true
: await isPurchaseEligible(user_id, record.subscription, purchasable_type, record[CHECKOUT_PK[purchasable_type]]);
return { product: product ?? null, has_purchased: !!hasPurchase, purchase_eligible: purchaseEligible };
} }
exports.getCourseCheckoutInfo = async (req, res) => { exports.getCourseCheckoutInfo = async (req, res) => {
+99 -14
View File
@@ -16,13 +16,19 @@ const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
const { mdl_UserTierGrants } = require('../../models/tiers/tier.associations');
const Asset = require('../../models/assets/assets.mdl'); const Asset = require('../../models/assets/assets.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const { onTierActivated } = require('../../services/achievements.service'); const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl'); const { Course } = require('../../models/courses/courses.mdl');
const Unit = require('../../models/courses/units.mdl');
const Lesson = require('../../models/courses/lessons.mdl');
const paymentSvc = require('../../services/payment.service'); const paymentSvc = require('../../services/payment.service');
const { snapshotPlanGrants } = require('../../services/tierGrants.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { sendEmail } = require('../../services/email.service');
const { fmtDate } = require('../../utils/datetime.util');
require('../../models/tiers/tier.associations'); require('../../models/tiers/tier.associations');
@@ -76,9 +82,25 @@ exports.getMyTier = async (req, res) => {
active_tiers: [freeTier], active_tiers: [freeTier],
top_tier: 'free', top_tier: 'free',
just_expired, just_expired,
my_grants: { course_ids: [], unit_ids: [], lesson_ids: [] },
}); });
} }
// Item-specific entitlement (Tier Plans v2) — every course/unit/lesson id
// granted by ANY of this user's currently-active tiers, flattened, so the
// client can compute per-plan overlap (see PlanList.jsx) without a
// separate endpoint per plan.
const myGrantRows = await mdl_UserTierGrants.findAll({
where: { user_tier_id: stillActive.map((t) => t.tier_id) },
attributes: ['item_type', 'item_id'],
});
const my_grants = { course_ids: [], unit_ids: [], lesson_ids: [] };
for (const g of myGrantRows) {
if (g.item_type === 'course') my_grants.course_ids.push(g.item_id);
else if (g.item_type === 'unit') my_grants.unit_ids.push(g.item_id);
else if (g.item_type === 'lesson') my_grants.lesson_ids.push(g.item_id);
}
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] }); const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
const rankMap = Object.fromEntries(categories.map((c) => [c.slug, c.rank])); const rankMap = Object.fromEntries(categories.map((c) => [c.slug, c.rank]));
@@ -104,7 +126,7 @@ exports.getMyTier = async (req, res) => {
// Spread topTierObj at top level too — keeps `myTier.tier`/`myTier.status`/ // Spread topTierObj at top level too — keeps `myTier.tier`/`myTier.status`/
// `myTier.category`/`myTier.expires_at` working for existing frontend code // `myTier.category`/`myTier.expires_at` working for existing frontend code
// that predates the active_tiers/top_tier shape (it'll just see the best tier). // that predates the active_tiers/top_tier shape (it'll just see the best tier).
return R.success(res, 'Active tier retrieved.', { ...topTierObj, active_tiers, top_tier, just_expired }); return R.success(res, 'Active tier retrieved.', { ...topTierObj, active_tiers, top_tier, just_expired, my_grants });
} catch (err) { } catch (err) {
console.error('[CLIENT][GET MY TIER]', err); console.error('[CLIENT][GET MY TIER]', err);
return R.error(res, 'Could not retrieve tier.', 500); return R.error(res, 'Could not retrieve tier.', 500);
@@ -129,6 +151,7 @@ exports.getMyTierHistory = async (req, res) => {
exports.getPlans = async (req, res) => { exports.getPlans = async (req, res) => {
try { try {
const plans = await mdl_TierPlans.findAll({ const plans = await mdl_TierPlans.findAll({
where: { status: 'published' },
order: [['tier', 'ASC'], ['duration_days', 'ASC']], order: [['tier', 'ASC'], ['duration_days', 'ASC']],
attributes: ['plan_id', 'tier', 'label', 'description', 'features', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'], attributes: ['plan_id', 'tier', 'label', 'description', 'features', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'],
include: [ include: [
@@ -138,12 +161,32 @@ exports.getPlans = async (req, res) => {
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'], attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
through: { attributes: [] }, through: { attributes: [] },
}, },
{
model: Unit,
as: 'units',
attributes: ['unit_id', 'uuid', 'title', 'duration_seconds'],
through: { attributes: [] },
},
{
model: Lesson,
as: 'lessons',
attributes: ['lesson_id', 'uuid', 'title', 'duration_seconds'],
through: { attributes: [] },
},
], ],
}); });
// Each plan holds exactly one bundle type (single-type bundles, Tier Plans
// v2) — expose the exact item id sets so the client can compute
// overlap-with-existing-access without extra round-trips (see PlanList.jsx).
const result = plans.map((p) => { const result = plans.map((p) => {
const plain = p.toJSON(); const plain = p.toJSON();
plain.course_count = plain.courses?.length ?? 0; plain.course_count = plain.courses?.length ?? 0;
plain.unit_count = plain.units?.length ?? 0;
plain.lesson_count = plain.lessons?.length ?? 0;
plain.course_ids = (plain.courses ?? []).map((c) => c.course_id);
plain.unit_ids = (plain.units ?? []).map((u) => u.unit_id);
plain.lesson_ids = (plain.lessons ?? []).map((l) => l.lesson_id);
return plain; return plain;
}); });
@@ -161,7 +204,7 @@ exports.validatePromo = async (req, res) => {
const { plan_id, code } = req.body; const { plan_id, code } = req.body;
if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400); if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true, status: 'published' } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404); if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
const policy = await paymentSvc.getPolicyForPlan(plan_id); const policy = await paymentSvc.getPolicyForPlan(plan_id);
@@ -181,14 +224,17 @@ exports.createOrder = async (req, res) => {
const { plan_id, promo_code } = req.body; const { plan_id, promo_code } = req.body;
if (!plan_id) return R.error(res, 'plan_id is required.', 400); if (!plan_id) return R.error(res, 'plan_id is required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true, status: 'published' } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404); if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
// Repurchasing a plan under a tier already held active is allowed — it // Repurchasing the SAME plan while it's already active is allowed — it
// extends the existing grant's expires_at (see captureOrder) rather than // extends the existing grant's expires_at (see captureOrder) rather than
// being blocked. Surfaced here only for checkout-page messaging. // being blocked. A different plan at the same tier slug is NOT the same
// purchase — it creates its own independent user_tiers row with its own
// item-specific grants, so this check is keyed on plan_id, not tier.
// Surfaced here only for checkout-page messaging.
const existingActive = await mdl_UserTiers.findOne({ const existingActive = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, tier: plan.tier, status: 'active' }, where: { user_id: req.user.user_id, plan_id, status: 'active' },
attributes: ['expires_at'], attributes: ['expires_at'],
}); });
@@ -313,15 +359,16 @@ exports.captureOrder = async (req, res) => {
return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402); return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402);
} }
// Repurchasing a plan under a tier already held active extends the // Repurchasing THIS SAME plan while already active extends its expires_at
// existing grant's expires_at by the new plan's duration, rather than // by the new duration, rather than being blocked/refunded. A different
// being blocked/refunded — the original plan_id is kept (whichever plan // plan — even at the same tier slug — is a distinct purchase and gets its
// first granted this tier keeps governing its bundle/access_rules; a // own user_tiers row with its own item-specific grants (see
// sibling-plan repurchase only adds time). This also keeps the // snapshotPlanGrants below); it must NOT be merged into an unrelated
// one-active-row-per-(user,tier) DB invariant intact, since no second // plan's row just because the tier slug matches (Tier Plans v2 — a Unit
// row is ever created. // bundle and a Course bundle can both be "premium" and both need to stay
// independently active/tracked).
const existingActive = await mdl_UserTiers.findOne({ const existingActive = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, tier: payment.plan.tier, status: 'active' }, where: { user_id: req.user.user_id, plan_id: payment.plan_id, status: 'active' },
}); });
let resultTier; let resultTier;
@@ -348,6 +395,12 @@ exports.captureOrder = async (req, res) => {
successMessage = 'Payment successful. Tier activated.'; successMessage = 'Payment successful. Tier activated.';
} }
// Snapshot the plan's current bundle contents into user_tier_grants —
// refreshed on every purchase/extension so an admin's bundle edits since
// the last purchase are picked up, but past purchasers of OTHER plans are
// never retroactively affected (Tier Plans v2 item-specific entitlement).
await snapshotPlanGrants(resultTier, payment.plan_id);
await payment.update({ await payment.update({
status: 'completed', status: 'completed',
tier_id: resultTier.tier_id, tier_id: resultTier.tier_id,
@@ -466,6 +519,38 @@ exports.refundOrder = async (req, res) => {
const now = new Date(); const now = new Date();
await activeTier.update({ status: 'revoked', expires_at: now, revoked_at: now }); await activeTier.update({ status: 'revoked', expires_at: now, revoked_at: now });
const plan = await mdl_TierPlans.findByPk(payment.plan_id, { attributes: ['plan_id', 'label'] });
try {
const notify = NOTIFICATION_REGISTRY.payment_refunded.build({
label: plan?.label ?? 'your plan',
amount: payment.amount,
currency: payment.currency,
planId: plan?.plan_id ?? null,
});
await UserNotification.create({ user_id, ...notify, seen: false });
} catch (notifyErr) {
console.error('[CLIENT][REFUND][NOTIFY]', notifyErr);
}
try {
const name = req.user.personal_info?.name?.full_name ?? 'there';
sendEmail({
to: req.user.email,
type: 'REFUND_PROCESSED',
data: {
name,
label: plan?.label ?? 'your plan',
amount: payment.amount,
currency: payment.currency,
date: fmtDate(now),
refundId: refundData.id,
},
}).catch((emailErr) => console.error('[CLIENT][REFUND][EMAIL]', emailErr));
} catch (emailErr) {
console.error('[CLIENT][REFUND][EMAIL]', emailErr);
}
// Only fall back to free if the user has no other concurrently active tier — // Only fall back to free if the user has no other concurrently active tier —
// revoking one subscription shouldn't drop them below a tier they still hold. // revoking one subscription shouldn't drop them below a tier they still hold.
const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } }); const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } });
+2 -6
View File
@@ -148,9 +148,7 @@ exports.getUnits = async (req, res) => {
: false; : false;
const product = productById.get(String(row.unit_id)) ?? null; const product = productById.get(String(row.unit_id)) ?? null;
const has_purchased = purchasedIds.has(String(row.unit_id)); const has_purchased = purchasedIds.has(String(row.unit_id));
const purchase_eligible = (is_locked && product && !has_purchased) const purchase_eligible = true;
? await coursesCtrl.isPurchaseEligible(req.user.user_id, row.subscription, "unit", row.unit_id)
: true;
result.push({ result.push({
...row, ...row,
courses: coursesByUnit.get(row.unit_id) ?? [], courses: coursesByUnit.get(row.unit_id) ?? [],
@@ -216,9 +214,7 @@ exports.getLessons = async (req, res) => {
: false; : false;
const product = productById.get(String(row.lesson_id)) ?? null; const product = productById.get(String(row.lesson_id)) ?? null;
const has_purchased = purchasedIds.has(String(row.lesson_id)); const has_purchased = purchasedIds.has(String(row.lesson_id));
const purchase_eligible = (is_locked && product && !has_purchased) const purchase_eligible = true;
? await coursesCtrl.isPurchaseEligible(req.user.user_id, row.subscription, "lesson", row.lesson_id)
: true;
result.push({ result.push({
...row, ...row,
courses: coursesByLesson.get(row.lesson_id) ?? [], courses: coursesByLesson.get(row.lesson_id) ?? [],
+32
View File
@@ -161,6 +161,38 @@ const emailTemplates = {
<p>For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.</p> <p>For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.</p>
`), `),
}), }),
TIER_ACCESS_REVOKED: ({ name, label, date }) => ({
subject: "Your Subscription Access Has Been Revoked - STARR System",
html: wrap(`
<p>Dear ${name},</p>
<p>We're writing to inform you that your access under the following subscription plan has been revoked by our team, effective <strong>${date}</strong>:</p>
<table style="${FONT}">
<tr><td><strong>Plan</strong></td><td>${label}</td></tr>
<tr><td><strong>Status</strong></td><td>Access Revoked</td></tr>
<tr><td><strong>Refund</strong></td><td>Not applicable — this subscription is non-refundable</td></tr>
</table>
<p>Your account no longer has access to the courses, units, and lessons included in this plan. If you were not concurrently subscribed to another active plan, your account has reverted to the Free tier.</p>
<p>If you believe this was done in error or have questions about this change, please contact our support team.</p>
`),
}),
REFUND_PROCESSED: ({ name, label, amount, currency, date, refundId }) => ({
subject: "Your Refund Has Been Processed - STARR System",
html: wrap(`
<p>Dear ${name},</p>
<p>We're writing to confirm that your refund request has been processed as of <strong>${date}</strong>:</p>
<table style="${FONT}">
<tr><td><strong>Plan</strong></td><td>${label}</td></tr>
<tr><td><strong>Amount Refunded</strong></td><td>${currency} ${amount}</td></tr>
<tr><td><strong>Refund ID</strong></td><td>${refundId}</td></tr>
<tr><td><strong>Status</strong></td><td>Refunded</td></tr>
</table>
<p>Your access to the courses, units, and lessons included in this plan has been revoked effective immediately. If you were not concurrently subscribed to another active plan, your account has reverted to the Free tier.</p>
<p>Please allow a few business days for the refunded amount to reflect on your original payment method, depending on your provider.</p>
<p>If you have any questions about this refund, please contact our support team.</p>
`),
}),
}; };
module.exports = { emailTemplates }; module.exports = { emailTemplates };
+35 -1
View File
@@ -23,7 +23,7 @@
* User : task_requirements_updated, task_submissions_closed, task_submissions_reopened, user_task_overdue, user_task_auto_completed, task_reminder, achievement, * User : task_requirements_updated, task_submissions_closed, task_submissions_reopened, user_task_overdue, user_task_auto_completed, task_reminder, achievement,
* course_unlocked, course_completed, certificate_issued, welcome, * course_unlocked, course_completed, certificate_issued, welcome,
* nogrp_welcome, assessment_updated, announcement, tier_expired, * nogrp_welcome, assessment_updated, announcement, tier_expired,
* tier_plan_archived, * tier_plan_archived, tier_plan_access_revoked, payment_refunded,
* task_submission_reviewed, task_assigned, task_completed * task_submission_reviewed, task_assigned, task_completed
* Both : broadcast (admin-composed, sent via notification_broadcasts CRUD) * Both : broadcast (admin-composed, sent via notification_broadcasts CRUD)
* *
@@ -393,6 +393,40 @@ const NOTIFICATION_REGISTRY = {
}, },
}, },
// Distinct from tier_plan_archived above — that one explicitly says access
// is unaffected, which would be false here, so an admin force-revoke
// always fires THIS instead of (never alongside) tier_plan_archived.
tier_plan_access_revoked: {
type: 'tier_plan_access_revoked',
scope: 'user',
trigger: 'event',
build({ label, planId = null }) {
return {
type: 'tier_plan_access_revoked',
title: 'Subscription Access Revoked',
message: `Your access under the "${label}" plan has been revoked by an administrator, effective immediately. This subscription is non-refundable.`,
data: { label, planId },
};
},
},
// Fired when a user self-serves a refund within their plan's refund window
// (controllers/client/tiers.controller.js refundOrder) — distinct from
// tier_plan_access_revoked above, which is an admin-initiated, non-refundable cutoff.
payment_refunded: {
type: 'payment_refunded',
scope: 'user',
trigger: 'event',
build({ label, amount, currency, planId = null }) {
return {
type: 'payment_refunded',
title: 'Refund Processed',
message: `Your refund of ${currency} ${amount} for the "${label}" plan has been processed. Your access to this plan has been revoked.`,
data: { label, amount, currency, planId },
};
},
},
}; };
module.exports = { NOTIFICATION_REGISTRY }; module.exports = { NOTIFICATION_REGISTRY };
@@ -0,0 +1,42 @@
'use strict';
// Tier Plans v2: bundle items (courses/units/lessons) may now belong to more
// than one plan simultaneously — the old single-column unique constraints
// enforced "one item = at most one plan ever", which blocks that. Replace
// each with a composite unique on (plan_id, item_id), which still prevents
// the same item being added twice to the *same* plan.
module.exports = {
async up(queryInterface) {
await queryInterface.removeConstraint('plan_courses', 'plan_courses_course_id_key').catch(() => {});
await queryInterface.removeIndex('plan_courses', 'plan_courses_course_id_key').catch(() => {});
await queryInterface.addIndex('plan_courses', ['plan_id', 'course_id'], {
unique: true,
name: 'plan_courses_plan_id_course_id_unique',
});
await queryInterface.removeConstraint('plan_units', 'plan_units_unit_id_key').catch(() => {});
await queryInterface.removeIndex('plan_units', 'plan_units_unit_id_key').catch(() => {});
await queryInterface.addIndex('plan_units', ['plan_id', 'unit_id'], {
unique: true,
name: 'plan_units_plan_id_unit_id_unique',
});
await queryInterface.removeConstraint('plan_lessons', 'plan_lessons_lesson_id_key').catch(() => {});
await queryInterface.removeIndex('plan_lessons', 'plan_lessons_lesson_id_key').catch(() => {});
await queryInterface.addIndex('plan_lessons', ['plan_id', 'lesson_id'], {
unique: true,
name: 'plan_lessons_plan_id_lesson_id_unique',
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeIndex('plan_courses', 'plan_courses_plan_id_course_id_unique');
await queryInterface.changeColumn('plan_courses', 'course_id', { type: Sequelize.BIGINT, allowNull: false, unique: true });
await queryInterface.removeIndex('plan_units', 'plan_units_plan_id_unit_id_unique');
await queryInterface.changeColumn('plan_units', 'unit_id', { type: Sequelize.BIGINT, allowNull: false, unique: true });
await queryInterface.removeIndex('plan_lessons', 'plan_lessons_plan_id_lesson_id_unique');
await queryInterface.changeColumn('plan_lessons', 'lesson_id', { type: Sequelize.BIGINT, allowNull: false, unique: true });
},
};
@@ -0,0 +1,48 @@
'use strict';
// Tier Plans v2: item-specific entitlement. Until now a Tier Plan purchase
// only recorded `user_tiers.plan_id` + tier rank; the actual courses/units/
// lessons granted were re-resolved LIVE off plan_courses/plan_units/
// plan_lessons on every access check, so editing a plan's bundle later
// silently changed what past purchasers could access, and a Unit-only bundle
// couldn't be distinguished from its parent Course's bundle.
//
// This table is the purchase-time snapshot: one row per exact item granted
// by a given user_tiers purchase. Access checks now look up rows here
// instead of comparing tier rank against item subscription (see hasItemGrant()
// in controllers/client/courses.controller.js).
//
// item_type is STRING + CHECK, not a real ENUM, mirroring
// products.purchasable_type (20270101000078) — CockroachDB can't create a
// new enum type via addColumn the way createTable can.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_tier_grants', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_tier_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'user_tiers', key: 'tier_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
plan_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'tier_plans', key: 'plan_id' }, onUpdate: 'CASCADE', onDelete: 'SET NULL' },
item_type: { type: Sequelize.STRING(10), allowNull: false },
item_id: { type: Sequelize.BIGINT, allowNull: false },
granted_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addConstraint('user_tier_grants', {
fields: ['item_type'],
type: 'check',
name: 'check_user_tier_grants_item_type',
where: { item_type: { [Sequelize.Op.in]: ['course', 'unit', 'lesson'] } },
});
await queryInterface.addIndex('user_tier_grants', ['user_id', 'item_type', 'item_id'], {
name: 'user_tier_grants_user_item_idx',
});
await queryInterface.addIndex('user_tier_grants', ['user_tier_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('user_tier_grants');
},
};
@@ -0,0 +1,38 @@
'use strict';
// Tier Plans v2: item-specific entitlement means two DIFFERENT plans at the
// SAME tier slug (e.g. two separate Premium bundles) must be able to stay
// concurrently active for one user, each governing its own distinct
// user_tier_grants snapshot — buying a second Premium bundle must not just
// extend the first one's expiry (see captureOrder in
// controllers/client/tiers.controller.js).
//
// The old partial unique index (20270101000075) enforced "one active row per
// (user_id, tier)", which blocks that. Replace it with "one active row per
// (user_id, plan_id)" — still prevents accidentally double-purchasing/
// double-granting the exact same plan concurrently, but allows multiple
// plans at the same tier to coexist. NULL plan_id rows (admin-granted
// tiers with no backing plan) are never considered equal to one another by
// a unique index, so they're unaffected.
//
// NOT auto-run — do not execute against the shared dev/prod database without
// explicit confirmation.
module.exports = {
async up(queryInterface) {
await queryInterface.removeIndex('user_tiers', 'user_tiers_one_active_per_user_tier');
await queryInterface.addIndex('user_tiers', ['user_id', 'plan_id'], {
unique: true,
where: { status: 'active' },
name: 'user_tiers_one_active_per_user_plan',
});
},
async down(queryInterface) {
await queryInterface.removeIndex('user_tiers', 'user_tiers_one_active_per_user_plan');
await queryInterface.addIndex('user_tiers', ['user_id', 'tier'], {
unique: true,
where: { status: 'active' },
name: 'user_tiers_one_active_per_user_tier',
});
},
};
@@ -0,0 +1,31 @@
'use strict';
// The "Access Rules" feature (rule-based access on top of/instead of item-
// specific entitlement — course_subscription_access/required_active_tier/
// group_restriction/item_allowlist rule types, plus the "starter-set purchase
// eligibility" gate derived from item_allowlist) has been removed entirely,
// admin UI and runtime enforcement both — item-specific `user_tier_grants` is
// the only entitlement mechanism now (see hasItemGrant() in
// controllers/client/courses.controller.js). No other table has an FK into
// plan_policies (only plan_policies.plan_id -> tier_plans.plan_id), so this
// is a clean, isolated drop.
//
// NOT auto-run — do not execute against the shared dev/prod database without
// explicit confirmation.
module.exports = {
async up(queryInterface) {
await queryInterface.dropTable('plan_policies');
},
async down(queryInterface, Sequelize) {
await queryInterface.createTable('plan_policies', {
policy_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: Sequelize.BIGINT, allowNull: false, unique: true,
references: { model: 'tier_plans', key: 'plan_id' },
onUpdate: 'CASCADE', onDelete: 'CASCADE' },
access_rules: { type: Sequelize.JSONB, allowNull: false, defaultValue: [] },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tier_plans', 'status', {
type: Sequelize.ENUM('draft', 'published'),
allowNull: false,
defaultValue: 'draft',
});
// Preserve current live behavior — every plan that already exists is
// visible on the client today (only is_active governs purchasability),
// so backfill all of them to 'published'. The 'draft' default only
// takes effect for plans created after this migration.
await queryInterface.sequelize.query(`UPDATE "tier_plans" SET "status" = 'published'`);
},
async down(queryInterface) {
await queryInterface.removeColumn('tier_plans', 'status');
},
};
+5 -3
View File
@@ -33,9 +33,11 @@ const Advertisement = sequelize.define("Advertisement", {
}, },
// ─── Content ────────────────────────────────────────────────────────────── // ─── Content ──────────────────────────────────────────────────────────────
// "image" = image only, "content" = badge/headline/description/CTAs alongside // Every ad is now created/edited as "content" (badge/headline/description/
// the image — an explicit admin choice made in the creation wizard, decoupled // image/link all mandatory) — applyAdvertisementFields on the backend fixes
// from placement/format. // this server-side rather than accepting it from the client. "image" is
// legacy-only, left on rows created before the Image Only / Text with Image
// toggle was removed, until they're next edited.
content_mode: { type: DataTypes.ENUM("image", "content"), allowNull: false, defaultValue: "image", label: "Content Mode", order: 3.5 }, content_mode: { type: DataTypes.ENUM("image", "content"), allowNull: false, defaultValue: "image", label: "Content Mode", order: 3.5 },
// Up to 2 outline-badge chips shown alongside the headline — see MAX_BADGE_LABELS. // Up to 2 outline-badge chips shown alongside the headline — see MAX_BADGE_LABELS.
badge_labels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "Badge Labels", order: 4 }, badge_labels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "Badge Labels", order: 4 },
+7 -1
View File
@@ -2,7 +2,13 @@
* File Name: plan_courses.mdl.js * File Name: plan_courses.mdl.js
* Type of Program: Model * Type of Program: Model
* Description: Junction table — links courses to a specific tier plan. * Description: Junction table — links courses to a specific tier plan.
* UNIQUE on course_id enforces one course belongs to one plan only. * Composite UNIQUE on (plan_id, course_id) only prevents adding
* the same course twice to the same plan — a course may belong
* to many plans simultaneously (Tier Plans v2, silent duplication
* across bundles is intentional; see admin/tiers.controller.js).
* Bundling/display only, not an access-control mechanism — see
* controllers/client/courses.controller.js (hasItemGrant) /
* user_tier_grants for entitlement.
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 6, 2026 * Date Created: Jun. 6, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
+5 -2
View File
@@ -2,9 +2,12 @@
* File Name: plan_lessons.mdl.js * File Name: plan_lessons.mdl.js
* Type of Program: Model * Type of Program: Model
* Description: Junction table — links standalone Lessons to a specific tier plan. * Description: Junction table — links standalone Lessons to a specific tier plan.
* UNIQUE on lesson_id enforces one lesson belongs to one plan only. * Composite UNIQUE on (plan_id, lesson_id) only prevents adding
* the same lesson twice to the same plan — a lesson may belong
* to many plans simultaneously (Tier Plans v2, silent duplication
* across bundles is intentional; see admin/tiers.controller.js).
* Mirrors plan_courses.mdl.js — bundling/display only, not an * Mirrors plan_courses.mdl.js — bundling/display only, not an
* access-control mechanism (see canAccessLesson). * access-control mechanism (see canAccessLesson / user_tier_grants).
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 1, 2026 * Date Created: Aug. 1, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
-14
View File
@@ -1,14 +0,0 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_PlanPolicies = sequelize.define('PlanPolicy', {
policy_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: DataTypes.BIGINT, allowNull: false, unique: true },
access_rules: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: 'Access Rules' },
}, {
tableName: 'plan_policies',
timestamps: true,
paranoid: false,
});
module.exports = mdl_PlanPolicies;
+5 -2
View File
@@ -2,9 +2,12 @@
* File Name: plan_units.mdl.js * File Name: plan_units.mdl.js
* Type of Program: Model * Type of Program: Model
* Description: Junction table — links standalone Units to a specific tier plan. * Description: Junction table — links standalone Units to a specific tier plan.
* UNIQUE on unit_id enforces one unit belongs to one plan only. * Composite UNIQUE on (plan_id, unit_id) only prevents adding the
* same unit twice to the same plan — a unit may belong to many
* plans simultaneously (Tier Plans v2, silent duplication across
* bundles is intentional; see admin/tiers.controller.js).
* Mirrors plan_courses.mdl.js — bundling/display only, not an * Mirrors plan_courses.mdl.js — bundling/display only, not an
* access-control mechanism (see canAccessUnit). * access-control mechanism (see canAccessUnit / user_tier_grants).
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 1, 2026 * Date Created: Aug. 1, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
+10 -8
View File
@@ -6,7 +6,7 @@ const mdl_Payments = require('./payments.mdl');
const mdl_PlanCourses = require('./plan_courses.mdl'); const mdl_PlanCourses = require('./plan_courses.mdl');
const mdl_PlanUnits = require('./plan_units.mdl'); const mdl_PlanUnits = require('./plan_units.mdl');
const mdl_PlanLessons = require('./plan_lessons.mdl'); const mdl_PlanLessons = require('./plan_lessons.mdl');
const mdl_PlanPolicies = require('./plan_policies.mdl'); const mdl_UserTierGrants = require('./user_tier_grants.mdl');
const mdl_SystemBadges = require('../system_badges/system_badges.mdl'); const mdl_SystemBadges = require('../system_badges/system_badges.mdl');
const Asset = require('../assets/assets.mdl'); const Asset = require('../assets/assets.mdl');
const { Course } = require('../courses/courses.mdl'); const { Course } = require('../courses/courses.mdl');
@@ -46,7 +46,7 @@ Course.belongsToMany(mdl_TierPlans, {
}); });
mdl_PlanCourses.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_PlanCourses.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_PlanCourses.belongsTo(Course, { foreignKey: 'course_id', as: 'course' }); mdl_PlanCourses.belongsTo(Course, { foreignKey: 'course_id', as: 'course' });
Course.hasOne(mdl_PlanCourses, { as: 'planCourse', foreignKey: 'course_id' }); Course.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'course_id' });
mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_id' }); mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_id' });
// ─── Plan ↔ Units ───────────────────────────────────────────────────────────── // ─── Plan ↔ Units ─────────────────────────────────────────────────────────────
@@ -64,7 +64,7 @@ Unit.belongsToMany(mdl_TierPlans, {
}); });
mdl_PlanUnits.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_PlanUnits.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_PlanUnits.belongsTo(Unit, { foreignKey: 'unit_id', as: 'unit' }); mdl_PlanUnits.belongsTo(Unit, { foreignKey: 'unit_id', as: 'unit' });
Unit.hasOne(mdl_PlanUnits, { as: 'planUnit', foreignKey: 'unit_id' }); Unit.hasMany(mdl_PlanUnits, { as: 'planUnits', foreignKey: 'unit_id' });
mdl_TierPlans.hasMany(mdl_PlanUnits, { as: 'planUnits', foreignKey: 'plan_id' }); mdl_TierPlans.hasMany(mdl_PlanUnits, { as: 'planUnits', foreignKey: 'plan_id' });
// ─── Plan ↔ Lessons ─────────────────────────────────────────────────────────── // ─── Plan ↔ Lessons ───────────────────────────────────────────────────────────
@@ -82,16 +82,18 @@ Lesson.belongsToMany(mdl_TierPlans, {
}); });
mdl_PlanLessons.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_PlanLessons.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_PlanLessons.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesson' }); mdl_PlanLessons.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesson' });
Lesson.hasOne(mdl_PlanLessons, { as: 'planLesson', foreignKey: 'lesson_id' }); Lesson.hasMany(mdl_PlanLessons, { as: 'planLessons', foreignKey: 'lesson_id' });
mdl_TierPlans.hasMany(mdl_PlanLessons, { as: 'planLessons', foreignKey: 'plan_id' }); mdl_TierPlans.hasMany(mdl_PlanLessons, { as: 'planLessons', foreignKey: 'plan_id' });
// ─── UserTier → Plan ────────────────────────────────────────────────────────── // ─── UserTier → Plan ──────────────────────────────────────────────────────────
mdl_UserTiers.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_UserTiers.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' }); mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' });
// ─── Plan ↔ Policy ──────────────────────────────────────────────────────────── // ─── UserTier → UserTierGrants (item-specific entitlement snapshot) ──────────
mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' }); mdl_UserTiers.hasMany(mdl_UserTierGrants, { foreignKey: 'user_tier_id', as: 'grants' });
mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_UserTierGrants.belongsTo(mdl_UserTiers, { foreignKey: 'user_tier_id', as: 'userTier' });
mdl_UserTierGrants.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
mdl_UserTierGrants.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
// ─── SystemBadge → Asset ───────────────────────────────────────────────────── // ─── SystemBadge → Asset ─────────────────────────────────────────────────────
mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' }); mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' });
@@ -104,6 +106,6 @@ module.exports = {
mdl_PlanCourses, mdl_PlanCourses,
mdl_PlanUnits, mdl_PlanUnits,
mdl_PlanLessons, mdl_PlanLessons,
mdl_PlanPolicies,
mdl_SystemBadges, mdl_SystemBadges,
mdl_UserTierGrants,
}; };
+1
View File
@@ -21,6 +21,7 @@ const mdl_TierPlans = sequelize.define('TierPlan', {
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' }, price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' },
currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Currency' }, currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Currency' },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' }, is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' },
status: { type: DataTypes.ENUM('draft', 'published'), allowNull: false, defaultValue: 'draft', label: 'Status', hidden: false, filterable: true },
createdBy: { type: DataTypes.BIGINT, allowNull: true }, createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true },
+30
View File
@@ -0,0 +1,30 @@
/***********************************************************************************************************************************************************************
* File Name: user_tier_grants.mdl.js
* Type of Program: Model
* Description: Purchase-time snapshot of the exact courses/units/lessons a
* Tier Plan purchase granted. Replaces live re-resolution off
* plan_courses/plan_units/plan_lessons at access-check time, so
* editing a plan's bundle later no longer retroactively changes
* what past purchasers can access. See hasItemGrant() in
* controllers/client/courses.controller.js.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 4, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_UserTierGrants = sequelize.define('UserTierGrant', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
user_tier_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User Tier ID' },
user_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User ID' },
plan_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Plan ID' },
item_type: { type: DataTypes.STRING(10), allowNull: false, label: 'Item Type' },
item_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Item ID' },
granted_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Granted At' },
}, {
tableName: 'user_tier_grants',
timestamps: true,
paranoid: false,
});
module.exports = mdl_UserTierGrants;
-6
View File
@@ -2,12 +2,6 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../../controllers/admin/tier_policies.controller'); const ctrl = require('../../controllers/admin/tier_policies.controller');
// ─── Plan Policies (access rules) ────────────────────────────────────────────
// GET /admin/tiers/plans/:planId/policy
// PUT /admin/tiers/plans/:planId/policy — JSON: { access_rules }
router.get('/plans/:planId/policy', ctrl.getPlanPolicy);
router.put('/plans/:planId/policy', ctrl.upsertPlanPolicy);
// ─── Payment Policies (promo codes + refund window) ─────────────────────────── // ─── Payment Policies (promo codes + refund window) ───────────────────────────
// GET /admin/tiers/plans/:planId/payment-policy // GET /admin/tiers/plans/:planId/payment-policy
// PUT /admin/tiers/plans/:planId/payment-policy — JSON: { promo_rules, refund_policy, allowed_providers } // PUT /admin/tiers/plans/:planId/payment-policy — JSON: { promo_rules, refund_policy, allowed_providers }
+89
View File
@@ -0,0 +1,89 @@
/***********************************************************************************************************************************************************************
* File Name: planAccess.service.js
* Type of Program: Service
* Description: Force-revokes access for every currently active subscriber of
* a Tier Plan (no refund). Always fired automatically as part of
* archivePlan/bulkArchivePlans (archiving a plan revokes its
* subscribers' access, full stop — no opt-in) and again as part
* of permanentlyDeletePlan/bulkPermanentlyDeletePlans, in case a
* plan was archived before this existed and still has active
* subscribers when it's finally deleted for good.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 4, 2026
***********************************************************************************************************************************************************************/
'use strict';
const mdl_UserTiers = require('../models/tiers/user_tiers.mdl');
const mdl_Users = require('../models/users/users.mdl');
const UserNotification = require('../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
const { sendEmail } = require('./email.service');
const { fmtDate } = require('../utils/datetime.util');
// Revokes every active user_tiers row tied to `plan`, replicating revokeTier's
// per-user "auto-downgrade to Free if no other active tier remains" rule
// (controllers/admin/tiers.controller.js) — a user can hold more than one
// concurrently-active plan, so this can't be a blanket status update.
async function revokePlanSubscriberAccess(plan, revokedByUserId) {
const activeRows = await mdl_UserTiers.findAll({
where: { plan_id: plan.plan_id, status: 'active' },
attributes: ['tier_id', 'user_id'],
});
if (!activeRows.length) return { revoked_user_count: 0 };
const tierIds = activeRows.map((r) => r.tier_id);
const userIds = [...new Set(activeRows.map((r) => String(r.user_id)))];
const now = new Date();
await mdl_UserTiers.update(
{ status: 'revoked', revoked_by: revokedByUserId, revoked_at: now },
{ where: { tier_id: tierIds } },
);
for (const user_id of userIds) {
const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } });
if (remainingActive === 0) {
await mdl_UserTiers.create({
user_id,
tier: 'free',
status: 'active',
starts_at: now,
expires_at: null,
granted_by: revokedByUserId,
notes: 'Auto-downgrade after plan access was force-revoked.',
});
}
}
try {
const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.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('[PLAN ACCESS REVOKE][NOTIFY]', notifyErr);
}
try {
const users = await mdl_Users.findAll({
where: { user_id: userIds },
attributes: ['user_id', 'email', 'personal_info'],
});
const dateStr = fmtDate(now);
for (const u of users) {
const name = u.personal_info?.name?.full_name ?? 'there';
sendEmail({
to: u.email,
type: 'TIER_ACCESS_REVOKED',
data: { name, label: plan.label, date: dateStr },
}).catch((emailErr) => console.error('[PLAN ACCESS REVOKE][EMAIL]', emailErr));
}
} catch (emailBatchErr) {
console.error('[PLAN ACCESS REVOKE][EMAIL BATCH]', emailBatchErr);
}
return { revoked_user_count: userIds.length };
}
module.exports = { revokePlanSubscriberAccess };
+56
View File
@@ -0,0 +1,56 @@
/***********************************************************************************************************************************************************************
* File Name: tierGrants.service.js
* Type of Program: Service
* Description: Snapshots a Tier Plan's current bundle contents (plan_courses/
* plan_units/plan_lessons — exactly one of the three per plan,
* per the single-type bundle rule) into user_tier_grants for a
* given user_tiers row. Shared by the client purchase-capture
* flow (controllers/client/tiers.controller.js#captureOrder) and
* the admin manual-grant flow (controllers/admin/tiers.controller.js#grantTier)
* so both paths produce the same item-specific entitlement.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 4, 2026
***********************************************************************************************************************************************************************/
'use strict';
const {
mdl_PlanCourses,
mdl_PlanUnits,
mdl_PlanLessons,
mdl_UserTierGrants,
} = require('../models/tiers/tier.associations');
// Replaces every grant row tied to this user_tiers purchase with a fresh
// snapshot of the plan's current bundle — safe to call again on repeat
// purchase/extension of the same plan (refreshes to whatever the plan
// currently contains, in case an admin edited it since the first purchase).
async function snapshotPlanGrants(userTierRow, planId) {
await mdl_UserTierGrants.destroy({ where: { user_tier_id: userTierRow.tier_id } });
if (!planId) return;
const [courseRows, unitRows, lessonRows] = await Promise.all([
mdl_PlanCourses.findAll({ where: { plan_id: planId }, attributes: ['course_id'] }),
mdl_PlanUnits.findAll({ where: { plan_id: planId }, attributes: ['unit_id'] }),
mdl_PlanLessons.findAll({ where: { plan_id: planId }, attributes: ['lesson_id'] }),
]);
const grants = [
...courseRows.map((r) => ({ item_type: 'course', item_id: r.course_id })),
...unitRows.map((r) => ({ item_type: 'unit', item_id: r.unit_id })),
...lessonRows.map((r) => ({ item_type: 'lesson', item_id: r.lesson_id })),
];
if (!grants.length) return;
await mdl_UserTierGrants.bulkCreate(
grants.map((g) => ({
user_tier_id: userTierRow.tier_id,
user_id: userTierRow.user_id,
plan_id: planId,
item_type: g.item_type,
item_id: g.item_id,
granted_at: new Date(),
})),
);
}
module.exports = { snapshotPlanGrants };
@@ -0,0 +1,82 @@
'use strict';
// ── Regression tests for item-specific entitlement (Tier Plans v2) ──────────
// Before this change, canAccessCourse/canAccessUnit/canAccessLesson unlocked
// content purely off tier RANK (any active Premium purchase unlocked every
// premium-tagged course). Now a Tier Plan purchase only grants the EXACT
// items its bundle contained at purchase time (recorded in
// user_tier_grants) — plain tier rank alone must no longer be sufficient.
// These tests exercise the real controller/model objects (Sequelize doesn't
// connect until a query actually runs) and stub only the specific static
// methods each scenario touches, via jest.spyOn.
const { Course } = require('../../models/courses/courses.associations');
const { mdl_UserTierGrants } = require('../../models/tiers/tier.associations');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Product = require('../../models/courses/products.mdl');
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const { canAccessCourse } = require('../../controllers/client/courses.controller');
function mockActiveTierUnused() {
jest.spyOn(mdl_UserTiers, 'findAll').mockResolvedValue([]);
jest.spyOn(mdl_TierCategories, 'findAll').mockResolvedValue([]);
jest.spyOn(mdl_UserGroupMembers, 'findAll').mockResolvedValue([]);
}
afterEach(() => jest.restoreAllMocks());
describe('canAccessCourse() — item-specific entitlement', () => {
test('free/ungated course is always accessible, no grant lookup needed', async () => {
jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'free', status: 'published' });
const grantSpy = jest.spyOn(mdl_UserTierGrants, 'findOne');
const result = await canAccessCourse(1, 100);
expect(result).toBe(true);
expect(grantSpy).not.toHaveBeenCalled();
});
test('unpublished course is never accessible regardless of entitlement', async () => {
jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'draft' });
const result = await canAccessCourse(1, 100);
expect(result).toBe(false);
});
test('premium course with no grant, no purchase, no active tier → denied (tier rank alone no longer grants access)', async () => {
jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'published' });
jest.spyOn(mdl_UserTierGrants, 'findOne').mockResolvedValue(null);
jest.spyOn(mdl_Product, 'findOne').mockResolvedValue(null);
mockActiveTierUnused();
const result = await canAccessCourse(1, 100);
expect(result).toBe(false);
});
test('premium course unlocked by an item-specific grant for THIS exact course', async () => {
jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'published' });
jest.spyOn(mdl_UserTierGrants, 'findOne').mockResolvedValue({ item_id: 100 }); // active grant found
mockActiveTierUnused();
const result = await canAccessCourse(1, 100);
expect(result).toBe(true);
});
test('premium course still unlocked via a valid individual (a la carte) purchase, unrelated to any tier grant', async () => {
jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'published' });
jest.spyOn(mdl_UserTierGrants, 'findOne').mockResolvedValue(null);
jest.spyOn(mdl_Product, 'findOne').mockResolvedValue({ id: 55 });
jest.spyOn(mdl_CoursePurchase, 'findOne').mockResolvedValue({ id: 1 });
mockActiveTierUnused();
const result = await canAccessCourse(1, 100);
expect(result).toBe(true);
});
});
@@ -22,6 +22,10 @@ jest.mock('../../models/courses/courses.mdl', () => ({ Course: {} }));
jest.mock('../../services/payment.service', () => ({ captureOrder: jest.fn() })); jest.mock('../../services/payment.service', () => ({ captureOrder: jest.fn() }));
jest.mock('../../data/notifications.data', () => ({ NOTIFICATION_REGISTRY: {} })); jest.mock('../../data/notifications.data', () => ({ NOTIFICATION_REGISTRY: {} }));
jest.mock('../../models/tiers/tier.associations', () => ({})); jest.mock('../../models/tiers/tier.associations', () => ({}));
// captureOrder now snapshots the plan's bundle into user_tier_grants on every
// successful capture (Tier Plans v2 item-specific entitlement) — mocked out
// here since these tests only pin the payment-status gating behavior.
jest.mock('../../services/tierGrants.service', () => ({ snapshotPlanGrants: jest.fn() }));
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl');
+91
View File
@@ -0,0 +1,91 @@
'use strict';
// ── Regression tests for the "revoke plan access, no refund" feature ────────
// revokePlanSubscriberAccess() must replicate revokeTier's single-record
// "auto-downgrade to Free only if the user holds no other active tier" rule
// (controllers/admin/tiers.controller.js) instead of blanket-downgrading
// everyone, and must fire exactly one in-app notification batch plus one
// email per affected user.
jest.mock('../../models/tiers/user_tiers.mdl', () => ({
findAll: jest.fn(),
update: jest.fn(),
count: jest.fn(),
create: jest.fn(),
}));
jest.mock('../../models/users/users.mdl', () => ({ findAll: jest.fn() }));
jest.mock('../../models/notifications/user_notification.mdl', () => ({ bulkCreate: jest.fn() }));
jest.mock('../../services/email.service', () => ({ sendEmail: jest.fn().mockResolvedValue(true) }));
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { sendEmail } = require('../../services/email.service');
const { revokePlanSubscriberAccess } = require('../../services/planAccess.service');
function makePlan(overrides = {}) {
return { plan_id: 10, label: 'Premium – 1 Month', ...overrides };
}
beforeEach(() => jest.clearAllMocks());
describe('revokePlanSubscriberAccess()', () => {
test('no active subscribers → no-op, returns zero count', async () => {
mdl_UserTiers.findAll.mockResolvedValue([]);
const result = await revokePlanSubscriberAccess(makePlan(), 99);
expect(result).toEqual({ revoked_user_count: 0 });
expect(mdl_UserTiers.update).not.toHaveBeenCalled();
expect(UserNotification.bulkCreate).not.toHaveBeenCalled();
expect(sendEmail).not.toHaveBeenCalled();
});
test('a user with ONLY this plan active gets auto-downgraded to Free', async () => {
mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 1, user_id: 5 }]);
mdl_UserTiers.count.mockResolvedValue(0); // no other active tier remains
mdl_Users.findAll.mockResolvedValue([{ user_id: 5, email: 'a@b.com', personal_info: { name: { full_name: 'Ana' } } }]);
const result = await revokePlanSubscriberAccess(makePlan(), 99);
expect(mdl_UserTiers.update).toHaveBeenCalledWith(
expect.objectContaining({ status: 'revoked', revoked_by: 99 }),
{ where: { tier_id: [1] } }
);
expect(mdl_UserTiers.create).toHaveBeenCalledWith(
expect.objectContaining({ user_id: '5', tier: 'free', status: 'active' })
);
expect(result).toEqual({ revoked_user_count: 1 });
});
test('a user with ANOTHER concurrently-active plan does NOT get downgraded', async () => {
mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 2, user_id: 6 }]);
mdl_UserTiers.count.mockResolvedValue(1); // still holds a different active plan
mdl_Users.findAll.mockResolvedValue([{ user_id: 6, email: 'c@d.com', personal_info: {} }]);
await revokePlanSubscriberAccess(makePlan(), 99);
expect(mdl_UserTiers.create).not.toHaveBeenCalled();
});
test('fires exactly one notification batch and one email per affected user', async () => {
mdl_UserTiers.findAll.mockResolvedValue([
{ tier_id: 1, user_id: 5 },
{ tier_id: 2, user_id: 6 },
]);
mdl_UserTiers.count.mockResolvedValue(1);
mdl_Users.findAll.mockResolvedValue([
{ user_id: 5, email: 'a@b.com', personal_info: {} },
{ user_id: 6, email: 'c@d.com', personal_info: {} },
]);
const result = await revokePlanSubscriberAccess(makePlan(), 99);
expect(UserNotification.bulkCreate).toHaveBeenCalledTimes(1);
expect(UserNotification.bulkCreate.mock.calls[0][0]).toHaveLength(2);
expect(sendEmail).toHaveBeenCalledTimes(2);
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: 'a@b.com', type: 'TIER_ACCESS_REVOKED' }));
expect(result).toEqual({ revoked_user_count: 2 });
});
});
-192
View File
@@ -1,192 +0,0 @@
'use strict';
const { evaluateCourseAccess, TIER_RANK } = require('../../utils/accessPolicy.util');
// ── Helpers ───────────────────────────────────────────────────────────────────
function ctx(tier = 'free', access_rules = [], group_ids = []) {
return { tier, access_rules, group_ids };
}
function course(subscription = 'free') {
return { subscription };
}
// ── Free course is always accessible ─────────────────────────────────────────
describe('free course (rank 0)', () => {
test('free user can access free course', () => {
expect(evaluateCourseAccess(ctx('free'), course('free'))).toEqual({ allowed: true, reason: null });
});
test('premium user can access free course', () => {
expect(evaluateCourseAccess(ctx('premium'), course('free'))).toEqual({ allowed: true, reason: null });
});
test('free user with no rules can still access free course', () => {
expect(evaluateCourseAccess(ctx('free', []), course('free'))).toEqual({ allowed: true, reason: null });
});
});
// ── No access_rules — fallback tier rank comparison ──────────────────────────
describe('no access_rules (fallback mode)', () => {
test('premium user can access premium course', () => {
const result = evaluateCourseAccess(ctx('premium', []), course('premium'), TIER_RANK);
expect(result).toEqual({ allowed: true, reason: null });
});
test('exclusive user can access exclusive course', () => {
const result = evaluateCourseAccess(ctx('exclusive', []), course('exclusive'), TIER_RANK);
expect(result).toEqual({ allowed: true, reason: null });
});
test('free user cannot access premium course', () => {
const result = evaluateCourseAccess(ctx('free', []), course('premium'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'tier_rank' });
});
test('premium user cannot access exclusive course', () => {
const result = evaluateCourseAccess(ctx('premium', []), course('exclusive'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'tier_rank' });
});
test('unknown course subscription slug → denied (safe default)', () => {
const result = evaluateCourseAccess(ctx('exclusive', []), course('unknown-tier'), TIER_RANK);
expect(result.allowed).toBe(false);
});
});
// ── Rule: course_subscription_access ─────────────────────────────────────────
describe('rule: course_subscription_access', () => {
const rules = [{ type: 'course_subscription_access', levels: ['free', 'premium'] }];
test('plan covers premium → allowed', () => {
const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK);
expect(result.allowed).toBe(true);
});
test('plan does not cover exclusive → denied', () => {
const result = evaluateCourseAccess(ctx('exclusive', rules), course('exclusive'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
});
});
// ── Rule: required_active_tier ────────────────────────────────────────────────
describe('rule: required_active_tier', () => {
const rules = [{ type: 'required_active_tier', tier: 'premium' }];
test('premium user meets premium requirement → allowed', () => {
const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK);
expect(result.allowed).toBe(true);
});
test('exclusive user exceeds premium requirement → allowed', () => {
const result = evaluateCourseAccess(ctx('exclusive', rules), course('premium'), TIER_RANK);
expect(result.allowed).toBe(true);
});
test('free user fails premium requirement → denied', () => {
const result = evaluateCourseAccess(ctx('free', rules), course('premium'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'required_tier' });
});
test('unknown required tier slug → always denied', () => {
const badRules = [{ type: 'required_active_tier', tier: 'ghost-tier' }];
const result = evaluateCourseAccess(ctx('exclusive', badRules), course('premium'), TIER_RANK);
expect(result.allowed).toBe(false);
});
});
// ── Rule: group_restriction ───────────────────────────────────────────────────
describe('rule: group_restriction', () => {
const rules = [{ type: 'group_restriction', group_ids: [5, 10] }];
test('user in allowed group → allowed', () => {
const result = evaluateCourseAccess(ctx('premium', rules, [10, 20]), course('premium'), TIER_RANK);
expect(result.allowed).toBe(true);
});
test('user not in any allowed group → denied', () => {
const result = evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'group_restriction' });
});
test('empty group_ids on rule → no restriction (passes)', () => {
const openRules = [{ type: 'group_restriction', group_ids: [] }];
const result = evaluateCourseAccess(ctx('premium', openRules, []), course('premium'), TIER_RANK);
expect(result.allowed).toBe(true);
});
});
// ── Rule: item_allowlist ───────────────────────────────────────────────────────
describe('rule: item_allowlist', () => {
const rules = [
{ type: 'course_subscription_access', levels: ['premium'] },
{ type: 'item_allowlist', item_type: 'course', item_ids: [42, 99] },
];
test('allowlisted exclusive course is granted even though the level-lock rule would block it', () => {
const result = evaluateCourseAccess(
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'course', id: 42 }
);
expect(result).toEqual({ allowed: true, reason: null });
});
test('matches item_ids as strings or numbers interchangeably', () => {
const result = evaluateCourseAccess(
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'course', id: '99' }
);
expect(result.allowed).toBe(true);
});
test('non-allowlisted exclusive course still falls through to the level-lock rule and is denied', () => {
const result = evaluateCourseAccess(
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'course', id: 7 }
);
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
});
test('item_type must match — a unit id matching a course-scoped allowlist is not granted', () => {
const result = evaluateCourseAccess(
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'unit', id: 42 }
);
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
});
test('no itemMeta (or null id) — item_allowlist is skipped entirely, existing callers unaffected', () => {
const result = evaluateCourseAccess(ctx('premium', rules), course('exclusive'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
});
test('allowlisted item still requires nothing when premium content is requested normally', () => {
const result = evaluateCourseAccess(
ctx('premium', rules), course('premium'), TIER_RANK, { type: 'course', id: 1 }
);
expect(result.allowed).toBe(true);
});
});
// ── Multiple rules evaluated together ────────────────────────────────────────
describe('multiple rules', () => {
test('fails if any one rule blocks', () => {
const rules = [
{ type: 'course_subscription_access', levels: ['premium', 'exclusive'] },
{ type: 'required_active_tier', tier: 'premium' },
{ type: 'group_restriction', group_ids: [7] },
];
// All pass
expect(evaluateCourseAccess(ctx('premium', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(true);
// Fails group_restriction
expect(evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK).allowed).toBe(false);
// Fails required_active_tier
expect(evaluateCourseAccess(ctx('free', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(false);
});
});
-108
View File
@@ -1,108 +0,0 @@
/***********************************************************************************************************************************************************************
* File Name: accessPolicy.util.js
* Type of Program: Utility
* Description: Evaluates a user's access to a course based on their active plan's access_rules JSONB.
*
* Rule types:
* course_subscription_access — { type, levels: ['free','premium','exclusive'] }
* → The plan grants access to these subscription levels only.
* required_active_tier — { type, tier: 'premium' | 'exclusive' }
* → The user's active tier must be at least this rank (exclusive satisfies premium).
* group_restriction — { type, group_ids: [number, ...] }
* → The user must belong to at least one of these groups.
* item_allowlist — { type, item_type: 'course'|'unit'|'lesson', item_ids: [id, ...] }
* → Grants access to these EXACT items regardless of level/tier/group — a
* curated "preview" override. Checked BEFORE the other rule types below;
* a match short-circuits straight to allowed, since it's meant to win
* even when a level-lock rule on the same plan would otherwise block it
* (e.g. Premium plan mostly locked to 'premium', but 2 specific
* Exclusive courses hand-picked as a preview).
*
* The 3 non-preview rule types combine as AND (any one can deny). item_allowlist
* is the one exception — it's an OR-style grant, not another AND constraint.
*
* Fallback (no access_rules): uses simple tier rank comparison.
***********************************************************************************************************************************************************************/
'use strict';
// Default rank map used as fallback when a live DB map is not available.
// Overridden at call time with ranks loaded from tier_categories.
const TIER_RANK = { free: 0, premium: 1, exclusive: 2 };
/**
* Evaluates whether a user can access a course (or, via itemMeta, a
* standalone unit/lesson — see courses.controller.js's canAccessUnit/
* canAccessLesson, which call this the same way canAccessCourse does).
*
* @param {object} ctx
* @param {string} ctx.tier — user's active tier slug
* @param {Array} ctx.access_rules — plan_policies.access_rules (may be empty)
* @param {number[]} ctx.group_ids — group IDs the user belongs to
* @param {object} course
* @param {string} course.subscription — course/unit/lesson subscription level (slug)
* @param {Object} tierRankMap — { [slug]: rank } loaded from tier_categories; falls back to TIER_RANK
* @param {Object} [itemMeta] — { type: 'course'|'unit'|'lesson', id } — identifies the
* specific item being checked, so item_allowlist rules can match it. Omit
* (or leave id null) to skip item_allowlist matching entirely.
* @returns {{ allowed: boolean, reason: string|null }}
*/
function evaluateCourseAccess(ctx, course, tierRankMap = TIER_RANK, itemMeta = {}) {
const { tier = 'free', access_rules = [], group_ids = [] } = ctx;
const { type: itemType = null, id: itemId = null } = itemMeta;
const courseSubscription = course.subscription ?? 'free';
const userRank = tierRankMap[tier] ?? 0;
// Unknown required slug → Infinity so access is always denied (safe default)
const courseRank = tierRankMap[courseSubscription] ?? Infinity;
// Rank-0 courses (default/free tier) are always accessible
if (courseRank === 0) return { allowed: true, reason: null };
// item_allowlist short-circuit — a curated preview item wins outright,
// bypassing level/tier/group checks below. Checked against every rule on
// the plan, not just when access_rules is otherwise empty.
if (itemId != null && access_rules && access_rules.length) {
for (const rule of access_rules) {
if (rule.type === 'item_allowlist' && rule.item_type === itemType) {
if ((rule.item_ids ?? []).map(String).includes(String(itemId))) {
return { allowed: true, reason: null };
}
}
}
}
// No plan policy — fallback: compare user rank vs course subscription rank
if (!access_rules || access_rules.length === 0) {
return userRank >= courseRank
? { allowed: true, reason: null }
: { allowed: false, reason: 'tier_rank' };
}
for (const rule of access_rules) {
if (rule.type === 'item_allowlist') continue; // handled above
if (rule.type === 'course_subscription_access') {
if (!(rule.levels ?? []).includes(courseSubscription)) {
return { allowed: false, reason: 'subscription_access' };
}
}
if (rule.type === 'required_active_tier') {
// Unknown rule tier slug → Infinity, so the rule always blocks
const reqRank = tierRankMap[rule.tier] ?? Infinity;
if (userRank < reqRank) {
return { allowed: false, reason: 'required_tier' };
}
}
if (rule.type === 'group_restriction') {
const required = (rule.group_ids ?? []).map(Number);
if (required.length > 0) {
const inGroup = required.some((gid) => group_ids.includes(gid));
if (!inGroup) return { allowed: false, reason: 'group_restriction' };
}
}
}
return { allowed: true, reason: null };
}
module.exports = { evaluateCourseAccess, TIER_RANK };