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()
// 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);
}
+3 -21
View File
@@ -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) {
+3 -20
View File
@@ -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']);
+74 -54
View File
@@ -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 })),
);
+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 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) {