mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
client and some admin new
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -102,14 +102,12 @@ async function applyAdvertisementFields(advertisement, body) {
|
||||
// status is intentionally NOT settable here — it's derived via deriveStatus()
|
||||
// right before save, based on is_active + start_date/end_date.
|
||||
|
||||
if (body.content_mode !== undefined) {
|
||||
if (!["image", "content"].includes(body.content_mode)) {
|
||||
const err = new Error(`Invalid content_mode. Must be one of: image, content`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
advertisement.content_mode = body.content_mode;
|
||||
}
|
||||
// content_mode is no longer an admin choice (the Image Only / Text with
|
||||
// Image toggle was removed — every ad now carries the same mandatory
|
||||
// badge/headline/description/image/link shape) — like type/format, it's
|
||||
// fixed server-side rather than trusted from the request body. Legacy
|
||||
// "image" mode rows keep that value until next edited.
|
||||
advertisement.content_mode = "content";
|
||||
|
||||
if (body.badge_labels !== undefined) advertisement.badge_labels = normalizeBadgeLabels(body.badge_labels);
|
||||
if (body.headline !== undefined) advertisement.headline = body.headline;
|
||||
@@ -149,6 +147,38 @@ async function applyAdvertisementFields(advertisement, body) {
|
||||
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
|
||||
advertisement.status = deriveStatus(advertisement);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ const {
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
|
||||
const { mdl_PlanCourses, mdl_TierPlans } = require("../../models/tiers/tier.associations");
|
||||
|
||||
const {
|
||||
excludeAttributes: courseExclude,
|
||||
computedAttributes: courseComputed,
|
||||
@@ -2403,28 +2401,12 @@ exports.getCoursesBySubscription = async (req, res) => {
|
||||
const rows = await Course.findAll({
|
||||
where: { ...notDeleted, subscription: slug },
|
||||
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']],
|
||||
});
|
||||
|
||||
// Flatten so the frontend can just check `assigned_plan` — a course belongs
|
||||
// to at most one plan (UNIQUE constraint on plan_courses.course_id).
|
||||
const data = rows.map((c) => {
|
||||
const plain = c.toJSON();
|
||||
const assigned_plan = plain.planCourse?.plan ?? null;
|
||||
delete plain.planCourse;
|
||||
return { ...plain, assigned_plan };
|
||||
});
|
||||
// A course may belong to any number of other plans (Tier Plans v2, silent
|
||||
// duplication across bundles is intentional) — no conflict to report here.
|
||||
const data = rows.map((c) => c.toJSON());
|
||||
|
||||
return R.success(res, 'Courses retrieved.', data);
|
||||
} catch (err) {
|
||||
|
||||
@@ -37,7 +37,6 @@ const {
|
||||
} = require("../../models/courses/courses.associations");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
const { mdl_PlanLessons, mdl_TierPlans } = require("../../models/tiers/tier.associations");
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||
@@ -189,28 +188,12 @@ exports.getLessonsBySubscription = async (req, res) => {
|
||||
const rows = await Lesson.findAll({
|
||||
where: { ...notDeleted, subscription: slug },
|
||||
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']],
|
||||
});
|
||||
|
||||
// Flatten so the frontend can just check `assigned_plan` — a lesson
|
||||
// belongs to at most one plan (UNIQUE constraint on plan_lessons.lesson_id).
|
||||
const data = rows.map((l) => {
|
||||
const plain = l.toJSON();
|
||||
const assigned_plan = plain.planLesson?.plan ?? null;
|
||||
delete plain.planLesson;
|
||||
return { ...plain, assigned_plan };
|
||||
});
|
||||
// A lesson may belong to any number of other plans (Tier Plans v2, silent
|
||||
// duplication across bundles is intentional) — no conflict to report here.
|
||||
const data = rows.map((l) => l.toJSON());
|
||||
|
||||
return R.success(res, 'Lessons retrieved.', data);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.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_SystemBadges = require('../../models/system_badges/system_badges.mdl');
|
||||
const Asset = require('../../models/assets/assets.mdl');
|
||||
@@ -11,112 +9,8 @@ const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
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'];
|
||||
|
||||
// ─── 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
const VALID_PROMO_TYPES = new Set(['flat', 'percent']);
|
||||
|
||||
@@ -28,9 +28,8 @@ const R = require('../../utils/response.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const { getFieldValues } = require('../../utils/fieldValues.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { resolveTierPlanUserIds } = require('../../utils/audienceResolver.util');
|
||||
const { snapshotPlanGrants } = require('../../services/tierGrants.service');
|
||||
const { revokePlanSubscriberAccess } = require('../../services/planAccess.service');
|
||||
|
||||
const {
|
||||
excludeAttributes: plansExclude,
|
||||
@@ -112,7 +111,7 @@ function computeDurationDays(value, unit) {
|
||||
|
||||
exports.createPlan = async (req, res) => {
|
||||
try {
|
||||
const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency, 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)
|
||||
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.slug,
|
||||
label, description, features, duration_days, duration_unit, price, currency,
|
||||
status: status ?? 'draft',
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
});
|
||||
const plain = plan.get({ plain: true });
|
||||
@@ -145,7 +145,7 @@ exports.updatePlan = async (req, res) => {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
||||
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 = {};
|
||||
for (const k of allowed) {
|
||||
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.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 {
|
||||
const userIds = await resolveTierPlanUserIds(plan.plan_id);
|
||||
if (userIds.length) {
|
||||
const now = new Date();
|
||||
const notify = NOTIFICATION_REGISTRY.tier_plan_archived.build({ label: plan.label, planId: plan.plan_id });
|
||||
await UserNotification.bulkCreate(
|
||||
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[ADMIN][ARCHIVE PLAN][NOTIFY]', notifyErr);
|
||||
({ revoked_user_count } = await revokePlanSubscriberAccess(plan, req.user?.user_id ?? null));
|
||||
} catch (revokeErr) {
|
||||
console.error('[ADMIN][ARCHIVE PLAN][REVOKE ACCESS]', revokeErr);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
|
||||
return R.success(res, 'Plan archived successfully.');
|
||||
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.', { revoked_user_count });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][ARCHIVE PLAN]', err);
|
||||
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.destroy({ where: { plan_id: activeIds } });
|
||||
|
||||
try {
|
||||
const holders = await mdl_UserTiers.findAll({
|
||||
attributes: ['user_id', 'plan_id'],
|
||||
where: { plan_id: activeIds, status: 'active' },
|
||||
raw: true,
|
||||
});
|
||||
if (holders.length) {
|
||||
const now = new Date();
|
||||
const labelByPlanId = new Map(activePlans.map((p) => [String(p.plan_id), p.label]));
|
||||
const notifications = holders.map(({ user_id, plan_id }) => ({
|
||||
user_id,
|
||||
...NOTIFICATION_REGISTRY.tier_plan_archived.build({ label: labelByPlanId.get(String(plan_id)), planId: plan_id }),
|
||||
seen: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
await UserNotification.bulkCreate(notifications, { validate: false });
|
||||
// Archiving always force-revokes current subscribers' access (no refund) —
|
||||
// each plan fires its own tier_plan_access_revoked (needs each plan's own
|
||||
// label), not the old batched "access unaffected" tier_plan_archived notice.
|
||||
let revoked_user_count = 0;
|
||||
for (const p of activePlans) {
|
||||
try {
|
||||
const { revoked_user_count: c } = await revokePlanSubscriberAccess(p, req.user?.user_id ?? null);
|
||||
revoked_user_count += c;
|
||||
} catch (revokeErr) {
|
||||
console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr);
|
||||
}
|
||||
} 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.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
revoked_user_count,
|
||||
});
|
||||
} catch (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.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 });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
|
||||
return R.success(res, 'Plan permanently deleted.');
|
||||
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.', { deleted_payment_count, revoked_user_count });
|
||||
} catch (err) {
|
||||
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);
|
||||
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);
|
||||
|
||||
// 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 });
|
||||
|
||||
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.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
deleted_payment_count,
|
||||
revoked_user_count,
|
||||
});
|
||||
} catch (err) {
|
||||
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);
|
||||
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);
|
||||
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({
|
||||
where: { user_id, tier, status: 'active' },
|
||||
where: { user_id, plan_id, status: 'active' },
|
||||
});
|
||||
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();
|
||||
@@ -448,6 +469,8 @@ exports.grantTier = async (req, res) => {
|
||||
notes,
|
||||
});
|
||||
|
||||
await snapshotPlanGrants(newTier, 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);
|
||||
} catch (err) {
|
||||
@@ -578,9 +601,8 @@ exports.syncPlanCourses = async (req, res) => {
|
||||
await mdl_PlanCourses.destroy({ where: { plan_id: id } });
|
||||
|
||||
if (course_ids.length) {
|
||||
// course_id has a UNIQUE constraint — clear any orphaned/other-plan assignments
|
||||
// for these courses before inserting, so the insert isn't silently skipped
|
||||
await mdl_PlanCourses.destroy({ where: { course_id: course_ids } });
|
||||
// A course may already belong to other plans — that's allowed (Tier Plans v2,
|
||||
// silent duplication across bundles), so we only ever touch this plan's own rows.
|
||||
await mdl_PlanCourses.bulkCreate(
|
||||
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 } });
|
||||
|
||||
if (unit_ids.length) {
|
||||
// unit_id has a UNIQUE constraint — clear any orphaned/other-plan assignments
|
||||
// for these units before inserting, so the insert isn't silently skipped
|
||||
await mdl_PlanUnits.destroy({ where: { unit_id: unit_ids } });
|
||||
// A unit may already belong to other plans — that's allowed (Tier Plans v2,
|
||||
// silent duplication across bundles), so we only ever touch this plan's own rows.
|
||||
await mdl_PlanUnits.bulkCreate(
|
||||
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 } });
|
||||
|
||||
if (lesson_ids.length) {
|
||||
// lesson_id has a UNIQUE constraint — clear any orphaned/other-plan assignments
|
||||
// for these lessons before inserting, so the insert isn't silently skipped
|
||||
await mdl_PlanLessons.destroy({ where: { lesson_id: lesson_ids } });
|
||||
// A lesson may already belong to other plans — that's allowed (Tier Plans v2,
|
||||
// silent duplication across bundles), so we only ever touch this plan's own rows.
|
||||
await mdl_PlanLessons.bulkCreate(
|
||||
lesson_ids.map(lesson_id => ({ plan_id: id, lesson_id })),
|
||||
);
|
||||
|
||||
@@ -41,7 +41,6 @@ const CompletionRequirement = require("../../models/courses/completion_requireme
|
||||
const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
const { mdl_PlanUnits, mdl_TierPlans } = require("../../models/tiers/tier.associations");
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||
@@ -192,28 +191,12 @@ exports.getUnitsBySubscription = async (req, res) => {
|
||||
const rows = await Unit.findAll({
|
||||
where: { ...notDeleted, subscription: slug },
|
||||
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']],
|
||||
});
|
||||
|
||||
// Flatten so the frontend can just check `assigned_plan` — a unit belongs
|
||||
// to at most one plan (UNIQUE constraint on plan_units.unit_id).
|
||||
const data = rows.map((u) => {
|
||||
const plain = u.toJSON();
|
||||
const assigned_plan = plain.planUnit?.plan ?? null;
|
||||
delete plain.planUnit;
|
||||
return { ...plain, assigned_plan };
|
||||
});
|
||||
// A unit may belong to any number of other plans (Tier Plans v2, silent
|
||||
// duplication across bundles is intentional) — no conflict to report here.
|
||||
const data = rows.map((u) => u.toJSON());
|
||||
|
||||
return R.success(res, 'Units retrieved.', data);
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,6 @@ const mdl_Product = require('../../models/courses/products.mdl');
|
||||
const paymentSvc = require('../../services/payment.service');
|
||||
const R = require('../../utils/response.util');
|
||||
const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util');
|
||||
const { isPurchaseEligible } = require('./courses.controller');
|
||||
|
||||
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
|
||||
// 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);
|
||||
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 ppOrder = await paymentSvc.createOrder('paypal', {
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
* Description: User-facing course endpoints (read-only).
|
||||
* Access rules:
|
||||
* - All courses are returned in the list (for upsell visibility)
|
||||
* - Each course has is_locked: boolean based on the user's active tier
|
||||
* - free / no active tier → unassigned courses are open; plan courses are locked
|
||||
* - premium (active tier) → unassigned + courses under their plan are open
|
||||
* - Each course has is_locked: boolean based on item-specific entitlement
|
||||
* (user_tier_grants — see hasItemGrant) or an individual purchase
|
||||
* - getCourse still enforces hard 403 on locked access
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 7, 2026
|
||||
@@ -16,12 +15,10 @@
|
||||
const { Op } = require("sequelize");
|
||||
const R = require("../../utils/response.util");
|
||||
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_Product = require("../../models/courses/products.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 {
|
||||
Course,
|
||||
@@ -34,7 +31,6 @@ const {
|
||||
} = require("../../models/courses/courses.associations");
|
||||
const { flattenUnits, flattenLessons } = require("../../utils/courses/hierarchy.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 { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
|
||||
const { onCourseCompleted } = require('../../services/achievements.service');
|
||||
@@ -90,43 +86,6 @@ async function expireSession(session, passingScore) {
|
||||
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
|
||||
// keyed by (purchasable_type, purchasable_id), see utils/purchasable.util.js.
|
||||
async function hasActivePurchase(user_id, purchasable_type, purchasable_id) {
|
||||
@@ -144,35 +103,39 @@ async function hasActivePurchase(user_id, purchasable_type, purchasable_id) {
|
||||
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 ─────────────────────────────────────
|
||||
// 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) {
|
||||
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription', 'status'] });
|
||||
if (!course) 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
|
||||
// falls back to rank comparison when access_rules is empty. Same behavior as
|
||||
// before for every course/plan combination that hasn't opted into the richer
|
||||
// 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;
|
||||
}
|
||||
// Item-specific entitlement: did any purchased Tier Plan bundle grant THIS
|
||||
// exact course?
|
||||
if (await hasItemGrant(user_id, 'course', course_id)) return true;
|
||||
|
||||
// Individual purchase as fallback
|
||||
return hasActivePurchase(user_id, 'course', course_id);
|
||||
@@ -187,30 +150,13 @@ async function canAccessCourse(user_id, course_id) {
|
||||
// genuinely standalone content run independently.
|
||||
|
||||
async function canAccessUnit(user_id, unit_id) {
|
||||
// A unit's own subscription (standalone tier-gating) is an additional,
|
||||
// OR'd access path alongside any attached course's access — most
|
||||
// standalone units have zero course links anyway, but a unit that somehow
|
||||
// has both should be unlockable via either.
|
||||
// A unit's own grant (item-specific — a Unit bundle purchase grants only
|
||||
// this unit, not its parent course) is an additional, OR'd access path
|
||||
// alongside any attached course's access — most standalone units have zero
|
||||
// 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'] });
|
||||
if (unit?.subscription) {
|
||||
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 (unit?.subscription && await hasItemGrant(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) {
|
||||
// Mirrors canAccessUnit's shape: own subscription, then own purchase, then
|
||||
// fall through to attached units (OR'd — a lesson can sit in more than one).
|
||||
// Mirrors canAccessUnit's shape: own grant, then own purchase, then fall
|
||||
// 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'] });
|
||||
if (lesson?.subscription) {
|
||||
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 (lesson?.subscription && await hasItemGrant(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;
|
||||
}
|
||||
|
||||
// ─── 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.canAccessUnit = canAccessUnit;
|
||||
exports.canAccessLesson = canAccessLesson;
|
||||
exports.isPurchaseEligible = isPurchaseEligible;
|
||||
|
||||
const COURSE_LIST_ATTRS = [
|
||||
"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) ─────────────────────────
|
||||
|
||||
exports.getCategories = async (req, res) => {
|
||||
@@ -367,9 +240,6 @@ exports.getCourses = async (req, res) => {
|
||||
try {
|
||||
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) —
|
||||
// course_purchases now spans all three content types, so filter down to
|
||||
// course-targeted products here.
|
||||
@@ -385,6 +255,16 @@ exports.getCourses = async (req, res) => {
|
||||
.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
|
||||
const categoryInclude = {
|
||||
model: mdl_Category,
|
||||
@@ -411,15 +291,13 @@ exports.getCourses = async (req, res) => {
|
||||
order: [['order_index', 'ASC'], ['title', 'ASC']],
|
||||
});
|
||||
|
||||
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
|
||||
|
||||
const result = courses.map((c) => {
|
||||
const plain = c.toJSON();
|
||||
const has_purchased = purchasedCourseIds.has(String(plain.course_id));
|
||||
const has_grant = grantedCourseIds.has(String(plain.course_id));
|
||||
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 };
|
||||
});
|
||||
@@ -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
|
||||
// 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
|
||||
@@ -607,9 +517,7 @@ exports.getCourse = async (req, res) => {
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
});
|
||||
const purchaseEligible = (!product || hasPurchase)
|
||||
? true
|
||||
: await isPurchaseEligible(req.user.user_id, plain.subscription, 'course', courseId);
|
||||
const purchaseEligible = true;
|
||||
|
||||
// Certificate status for the course details card
|
||||
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() } }],
|
||||
},
|
||||
});
|
||||
const purchaseEligible = (!product || hasPurchase)
|
||||
? true
|
||||
: await isPurchaseEligible(user_id, record.subscription, purchasable_type, record[CHECKOUT_PK[purchasable_type]]);
|
||||
return { product: product ?? null, has_purchased: !!hasPurchase, purchase_eligible: purchaseEligible };
|
||||
return { product: product ?? null, has_purchased: !!hasPurchase, purchase_eligible: true };
|
||||
}
|
||||
|
||||
exports.getCourseCheckoutInfo = async (req, res) => {
|
||||
|
||||
@@ -16,13 +16,19 @@ const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
const mdl_Payments = require('../../models/tiers/payments.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 UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { onTierActivated } = require('../../services/achievements.service');
|
||||
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 { snapshotPlanGrants } = require('../../services/tierGrants.service');
|
||||
const R = require('../../utils/response.util');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { sendEmail } = require('../../services/email.service');
|
||||
const { fmtDate } = require('../../utils/datetime.util');
|
||||
|
||||
require('../../models/tiers/tier.associations');
|
||||
|
||||
@@ -76,9 +82,25 @@ exports.getMyTier = async (req, res) => {
|
||||
active_tiers: [freeTier],
|
||||
top_tier: 'free',
|
||||
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 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`/
|
||||
// `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).
|
||||
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) {
|
||||
console.error('[CLIENT][GET MY TIER]', err);
|
||||
return R.error(res, 'Could not retrieve tier.', 500);
|
||||
@@ -129,6 +151,7 @@ exports.getMyTierHistory = async (req, res) => {
|
||||
exports.getPlans = async (req, res) => {
|
||||
try {
|
||||
const plans = await mdl_TierPlans.findAll({
|
||||
where: { status: 'published' },
|
||||
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
|
||||
attributes: ['plan_id', 'tier', 'label', 'description', 'features', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'],
|
||||
include: [
|
||||
@@ -138,12 +161,32 @@ exports.getPlans = async (req, res) => {
|
||||
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
|
||||
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 plain = p.toJSON();
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -161,7 +204,7 @@ exports.validatePromo = async (req, res) => {
|
||||
const { plan_id, code } = req.body;
|
||||
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);
|
||||
|
||||
const policy = await paymentSvc.getPolicyForPlan(plan_id);
|
||||
@@ -181,14 +224,17 @@ exports.createOrder = async (req, res) => {
|
||||
const { plan_id, promo_code } = req.body;
|
||||
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);
|
||||
|
||||
// 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
|
||||
// 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({
|
||||
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'],
|
||||
});
|
||||
|
||||
@@ -313,15 +359,16 @@ exports.captureOrder = async (req, res) => {
|
||||
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
|
||||
// existing grant's expires_at by the new plan's duration, rather than
|
||||
// being blocked/refunded — the original plan_id is kept (whichever plan
|
||||
// first granted this tier keeps governing its bundle/access_rules; a
|
||||
// sibling-plan repurchase only adds time). This also keeps the
|
||||
// one-active-row-per-(user,tier) DB invariant intact, since no second
|
||||
// row is ever created.
|
||||
// Repurchasing THIS SAME plan while already active extends its expires_at
|
||||
// by the new duration, rather than being blocked/refunded. A different
|
||||
// plan — even at the same tier slug — is a distinct purchase and gets its
|
||||
// own user_tiers row with its own item-specific grants (see
|
||||
// snapshotPlanGrants below); it must NOT be merged into an unrelated
|
||||
// plan's row just because the tier slug matches (Tier Plans v2 — a Unit
|
||||
// bundle and a Course bundle can both be "premium" and both need to stay
|
||||
// independently active/tracked).
|
||||
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;
|
||||
@@ -348,6 +395,12 @@ exports.captureOrder = async (req, res) => {
|
||||
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({
|
||||
status: 'completed',
|
||||
tier_id: resultTier.tier_id,
|
||||
@@ -466,6 +519,38 @@ exports.refundOrder = async (req, res) => {
|
||||
const now = new Date();
|
||||
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 —
|
||||
// revoking one subscription shouldn't drop them below a tier they still hold.
|
||||
const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } });
|
||||
|
||||
@@ -148,9 +148,7 @@ exports.getUnits = async (req, res) => {
|
||||
: false;
|
||||
const product = productById.get(String(row.unit_id)) ?? null;
|
||||
const has_purchased = purchasedIds.has(String(row.unit_id));
|
||||
const purchase_eligible = (is_locked && product && !has_purchased)
|
||||
? await coursesCtrl.isPurchaseEligible(req.user.user_id, row.subscription, "unit", row.unit_id)
|
||||
: true;
|
||||
const purchase_eligible = true;
|
||||
result.push({
|
||||
...row,
|
||||
courses: coursesByUnit.get(row.unit_id) ?? [],
|
||||
@@ -216,9 +214,7 @@ exports.getLessons = async (req, res) => {
|
||||
: false;
|
||||
const product = productById.get(String(row.lesson_id)) ?? null;
|
||||
const has_purchased = purchasedIds.has(String(row.lesson_id));
|
||||
const purchase_eligible = (is_locked && product && !has_purchased)
|
||||
? await coursesCtrl.isPurchaseEligible(req.user.user_id, row.subscription, "lesson", row.lesson_id)
|
||||
: true;
|
||||
const purchase_eligible = true;
|
||||
result.push({
|
||||
...row,
|
||||
courses: coursesByLesson.get(row.lesson_id) ?? [],
|
||||
|
||||
Reference in New Issue
Block a user