chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
@@ -0,0 +1,714 @@
/***********************************************************************************************************************************************************************
* File Name: tiers.controller.js (admin)
* Type of Program: Controller
* Description: Admin-level tier and plan management.
* - CRUD + archive/restore for tier_plans
* - View/grant/revoke user tiers
* - Paginated payments list
* Author: rgrgogu
* Date Created: Jun. 6, 2026
***********************************************************************************************************************************************************************/
const { Op, ForeignKeyConstraintError } = require('sequelize');
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
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_Users = require('../../models/users/users.mdl');
const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl');
const mdl_PlanUnits = require('../../models/tiers/plan_units.mdl');
const mdl_PlanLessons = require('../../models/tiers/plan_lessons.mdl');
const { Course } = require('../../models/courses/courses.mdl');
const Unit = require('../../models/courses/units.mdl');
const Lesson = require('../../models/courses/lessons.mdl');
require('../../models/tiers/tier.associations');
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 { snapshotPlanGrants } = require('../../services/tierGrants.service');
const { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk } = require('../../services/planAccess.service');
const {
excludeAttributes: plansExclude,
jsonbSchemas: plansSchemas,
computedAttributes: plansComputed,
} = require('../../models/tiers/tier_plans.attributes');
const {
excludeAttributes: paymentsExclude,
jsonbSchemas: paymentsSchemas,
computedAttributes: paymentsComputed,
} = require('../../models/tiers/payments.attributes');
const cc = require('currency-codes');
const PENDING_PAYMENT_EXPIRY_MINUTES = 60;
// ─── CURRENCIES ───────────────────────────────────────────────────────────────
exports.getCurrencies = (req, res) => {
const list = cc.codes().map((code) => {
const entry = cc.code(code);
return { code: entry.code, name: entry.currency };
}).sort((a, b) => a.code.localeCompare(b.code));
return R.success(res, 'OK', list);
};
const expireStalePendingPayments = async () => {
const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000);
await mdl_Payments.update(
{ status: 'expired' },
{ where: { status: 'pending', createdAt: { [Op.lt]: expiresBefore } } }
);
};
// ─── PLANS ────────────────────────────────────────────────────────────────────
exports.getPlans = async (req, res) => {
try {
const archived = req.query.archived === 'true';
const result = await paginate(mdl_TierPlans, req, {
excludeAttributes: plansExclude,
jsonbSchemas: plansSchemas,
computedAttributes: plansComputed,
context: archived ? 'archived' : 'list',
auditOptions: { mdl_Users, parentAlias: 'TierPlan' },
findOptions: archived ? {
paranoid: false,
where: { deletedAt: { [Op.ne]: null } },
} : {},
});
return R.success(res, 'Plans retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET PLANS]', err);
return R.error(res, 'Could not retrieve plans.', 500);
}
};
exports.getPlan = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
return R.success(res, 'Plan retrieved.', plan);
} catch (err) {
console.error('[ADMIN][GET PLAN]', err);
return R.error(res, 'Could not retrieve plan.', 500);
}
};
const DURATION_UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
function computeDurationDays(value, unit) {
const multiplier = DURATION_UNIT_TO_DAYS[unit] ?? 1;
return parseFloat(value) * multiplier;
}
exports.createPlan = async (req, res) => {
try {
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);
const category = await mdl_TierCategories.findByPk(tier_category_id);
if (!category || !category.is_active)
return R.error(res, 'Subscription category not found or inactive.', 404);
if (category.is_default)
return R.error(res, 'Plans cannot be created under the default (Free) subscription. Free access is automatic.', 400);
const duration_days = computeDurationDays(duration_value, duration_unit);
const plan = await mdl_TierPlans.create({
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 });
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
return R.success(res, 'Plan created.', { ...plain, plan_id: String(plain.plan_id) }, 201);
} catch (err) {
console.error('[ADMIN][CREATE PLAN]', err);
return R.error(res, 'Could not create plan.', 500);
}
};
exports.updatePlan = async (req, res) => {
try {
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', 'is_recommended', 'status', 'tier_category_id'];
const updates = {};
for (const k of allowed) {
if (req.body[k] !== undefined) updates[k] = req.body[k];
}
updates.updatedBy = req.body.updatedBy ?? req.user?.user_id ?? null;
// Recompute duration_days when value or unit changes
const { duration_value, duration_unit } = req.body;
if (duration_value !== undefined) {
const unit = duration_unit ?? plan.duration_unit ?? 'day';
updates.duration_days = computeDurationDays(duration_value, unit);
updates.duration_unit = unit;
} else if (duration_unit !== undefined) {
updates.duration_unit = duration_unit;
}
// If tier_category_id is being changed, sync the tier slug
if (updates.tier_category_id) {
const category = await mdl_TierCategories.findByPk(updates.tier_category_id);
if (!category || !category.is_active)
return R.error(res, 'Subscription category not found or inactive.', 404);
if (category.is_default)
return R.error(res, 'Plans cannot be moved to the Free subscription category.', 400);
updates.tier = category.slug;
}
await plan.update(updates);
logActivity(req.user?.user_id, 'update_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
return R.success(res, 'Plan updated.', plan);
} catch (err) {
console.error('[ADMIN][UPDATE PLAN]', err);
return R.error(res, 'Could not update plan.', 500);
}
};
exports.getPlanImpact = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
const active_subscriber_count = await mdl_UserTiers.count({
where: { plan_id: req.params.id, status: 'active' },
});
return R.success(res, 'Plan impact retrieved.', { active_subscriber_count });
} catch (err) {
console.error('[ADMIN][GET PLAN IMPACT]', err);
return R.error(res, 'Could not retrieve plan impact.', 500);
}
};
exports.archivePlan = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
if (plan.deletedAt) return R.error(res, 'Plan is already archived.', 400);
await plan.update({ is_active: false, 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 {
({ 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, 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);
}
};
exports.bulkArchivePlans = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No plan IDs provided.', 400);
const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids } });
if (!plans.length) return R.error(res, 'No plans found.', 404);
const activePlans = plans.filter((p) => !p.deletedAt);
if (!activePlans.length)
return R.error(res, 'All selected plans are already archived.', 400);
const activeIds = activePlans.map((p) => p.plan_id);
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 } });
// Archiving always force-revokes current subscribers' access (no refund) —
// one batched call across all selected plans (each still fires its own
// tier_plan_access_revoked with its own label) instead of one revoke call
// per plan, so this stays O(1) DB round trips regardless of selection size.
let revoked_user_count = 0;
try {
({ revoked_user_count } = await revokePlanSubscriberAccessBulk(activePlans, req.user?.user_id ?? null));
} catch (revokeErr) {
console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr);
}
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);
return R.error(res, 'Could not archive plans.', 500);
}
};
exports.restorePlan = async (req, res) => {
try {
const plan = await mdl_TierPlans.findOne({
where: { plan_id: req.params.id }, paranoid: false,
});
if (!plan) return R.error(res, 'Plan not found.', 404);
if (!plan.deletedAt) return R.error(res, 'Plan is not archived.', 400);
await plan.restore();
await plan.update({ is_active: true });
logActivity(req.user?.user_id, 'restore_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
return R.success(res, 'Plan restored successfully.');
} catch (err) {
console.error('[ADMIN][RESTORE PLAN]', err);
return R.error(res, 'Could not restore plan.', 500);
}
};
exports.bulkRestorePlans = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No plan IDs provided.', 400);
const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids }, paranoid: false });
if (!plans.length) return R.error(res, 'No plans found.', 404);
const archivedPlans = plans.filter((p) => p.deletedAt);
if (!archivedPlans.length)
return R.error(res, 'All selected plans are already active.', 400);
const archivedIds = archivedPlans.map((p) => p.plan_id);
await mdl_TierPlans.restore({ where: { plan_id: archivedIds } });
await mdl_TierPlans.update({ is_active: true }, { where: { plan_id: archivedIds }, paranoid: false });
logActivity(req.user?.user_id, 'bulk_restore_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} plan(s) restored successfully.`, {
restored_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
});
} catch (err) {
console.error('[ADMIN][BULK RESTORE PLANS]', err);
return R.error(res, 'Could not restore plans.', 500);
}
};
exports.getPlanPermanentDeleteImpact = async (req, res) => {
try {
const plan = await mdl_TierPlans.findOne({ where: { plan_id: req.params.id }, paranoid: false });
if (!plan) return R.error(res, 'Plan not found.', 404);
const active_subscriber_count = await mdl_UserTiers.count({
where: { plan_id: req.params.id, status: 'active' },
});
const payment_count = await mdl_Payments.count({ where: { plan_id: req.params.id } });
return R.success(res, 'Plan permanent-delete impact retrieved.', { active_subscriber_count, payment_count });
} catch (err) {
console.error('[ADMIN][GET PLAN PERMANENT DELETE IMPACT]', err);
return R.error(res, 'Could not retrieve plan permanent-delete impact.', 500);
}
};
exports.permanentlyDeletePlan = async (req, res) => {
try {
const plan = await mdl_TierPlans.findOne({
where: { plan_id: req.params.id }, paranoid: false,
});
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, 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 records on file referencing it.', 400);
}
console.error('[ADMIN][PERMANENT DELETE PLAN]', err);
return R.error(res, 'Could not permanently delete plan.', 500);
}
};
exports.bulkPermanentlyDeletePlans = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No plan IDs provided.', 400);
const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids }, paranoid: false });
if (!plans.length) return R.error(res, 'No plans found.', 404);
const archivedPlans = plans.filter((p) => p.deletedAt);
if (!archivedPlans.length)
return R.error(res, 'All selected plans must be archived before they can be permanently deleted.', 400);
const archivedIds = archivedPlans.map((p) => p.plan_id);
// Same reasoning as the single-delete path above: revoke any remaining
// active subscribers (each plan still gets its own label on the
// notification/email) before the records are gone for good — batched in
// one call across all selected plans instead of one call per plan.
const { revoked_user_count } = await revokePlanSubscriberAccessBulk(archivedPlans, req.user?.user_id ?? null);
// 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, 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 records on file referencing them.', 400);
}
console.error('[ADMIN][BULK PERMANENT DELETE PLANS]', err);
return R.error(res, 'Could not permanently delete plans.', 500);
}
};
exports.getPlanFieldValues = getFieldValues(mdl_TierPlans, 'TIER_PLAN', {
blockedFields: [],
});
// ─── USER TIERS ───────────────────────────────────────────────────────────────
exports.getUserTiers = async (req, res) => {
try {
const tiers = await mdl_UserTiers.findAll({
where: { user_id: req.params.id },
include: [
{ model: mdl_Users, as: 'grantedByUser', attributes: ['user_id', 'email'] },
{ model: mdl_Users, as: 'revokedByUser', attributes: ['user_id', 'email'] },
],
order: [['createdAt', 'DESC']],
});
return R.success(res, 'User subscriptions retrieved.', tiers);
} catch (err) {
console.error('[ADMIN][GET USER TIERS]', err);
return R.error(res, 'Could not retrieve user subscriptions.', 500);
}
};
exports.grantTier = async (req, res) => {
try {
const { user_id, plan_id, notes } = req.body;
if (!user_id || !plan_id)
return R.error(res, 'user_id and plan_id are required.', 400);
const user = await mdl_Users.findByPk(user_id);
if (!user) return R.error(res, 'User not found.', 404);
const plan = await mdl_TierPlans.findByPk(plan_id);
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, plan_id, status: 'active' },
});
if (existingActive) {
return R.error(res, `User already has this plan active until ${existingActive.expires_at}.`, 409);
}
const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({
user_id, tier, plan_id, status: 'active',
starts_at: startsAt,
expires_at: expiresAt,
granted_by: req.user.user_id,
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, 'Subscription granted.', newTier, 201);
} catch (err) {
console.error('[ADMIN][GRANT TIER]', err);
return R.error(res, 'Could not grant subscription.', 500);
}
};
exports.revokeTier = async (req, res) => {
try {
const tierRecord = await mdl_UserTiers.findByPk(req.params.tid);
if (!tierRecord) return R.error(res, 'Subscription record not found.', 404);
if (tierRecord.status !== 'active') return R.error(res, 'Subscription is not active.', 400);
await tierRecord.update({
status: 'revoked',
revoked_by: req.user.user_id,
revoked_at: new Date(),
});
// 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: tierRecord.user_id, status: 'active' },
});
if (remainingActive === 0) {
await mdl_UserTiers.create({
user_id: tierRecord.user_id,
tier: 'free',
status: 'active',
starts_at: new Date(),
expires_at: null,
granted_by: req.user.user_id,
notes: 'Auto-downgrade after revoke.',
});
}
logActivity(req.user.user_id, 'revoke_tier', { entityType: 'tier', details: { user_id: tierRecord.user_id, tier: tierRecord.tier } });
return R.success(res, remainingActive === 0 ? 'Subscription revoked. User downgraded to free.' : 'Subscription revoked.');
} catch (err) {
console.error('[ADMIN][REVOKE TIER]', err);
return R.error(res, 'Could not revoke subscription.', 500);
}
};
// ─── PAYMENTS ─────────────────────────────────────────────────────────────────
exports.getPayments = async (req, res) => {
try {
await expireStalePendingPayments();
const result = await paginate(mdl_Payments, req, {
excludeAttributes: paymentsExclude,
jsonbSchemas: paymentsSchemas,
computedAttributes: paymentsComputed,
context: 'list',
findOptions: {
include: [
{ model: mdl_Users, as: 'user', attributes: ['user_id', 'email'] },
{ model: mdl_TierPlans, as: 'plan', attributes: ['plan_id', 'label', 'tier', 'duration_days'] },
],
},
});
return R.success(res, 'Payments retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET PAYMENTS]', err);
return R.error(res, 'Could not retrieve payments.', 500);
}
};
exports.getPayment = async (req, res) => {
try {
await expireStalePendingPayments();
const payment = await mdl_Payments.findByPk(req.params.id, {
include: [
{ model: mdl_Users, as: 'user', attributes: ['user_id', 'email'] },
{ model: mdl_TierPlans, as: 'plan' },
{ model: mdl_UserTiers, as: 'tier' },
],
});
if (!payment) return R.error(res, 'Payment not found.', 404);
return R.success(res, 'Payment retrieved.', payment);
} catch (err) {
console.error('[ADMIN][GET PAYMENT]', err);
return R.error(res, 'Could not retrieve payment.', 500);
}
};
exports.getPaymentFieldValues = getFieldValues(mdl_Payments, 'PAYMENT', {
blockedFields: ['provider_payload'],
});
// ─── PLAN COURSES ─────────────────────────────────────────────────────────────
exports.getPlanCourses = async (req, res) => {
try {
const entries = await mdl_PlanCourses.findAll({
where: { plan_id: req.params.id },
include: [{
model: Course,
as: 'course',
attributes: ['course_id', 'title', 'course_code', 'subscription', 'level'],
}],
order: [['createdAt', 'ASC']],
});
return R.success(res, 'Plan courses retrieved.', entries.map(e => e.course));
} catch (err) {
console.error('[ADMIN][GET PLAN COURSES]', err);
return R.error(res, 'Could not retrieve plan courses.', 500);
}
};
exports.syncPlanCourses = async (req, res) => {
try {
const { id } = req.params;
const { course_ids = [] } = req.body;
if (!Array.isArray(course_ids))
return R.error(res, 'course_ids must be an array.', 400);
const plan = await mdl_TierPlans.findByPk(id);
if (!plan) return R.error(res, 'Plan not found.', 404);
// Remove all courses from this plan
await mdl_PlanCourses.destroy({ where: { plan_id: id } });
if (course_ids.length) {
// 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 })),
);
}
logActivity(req.user?.user_id, 'sync_plan_courses', { entityType: 'tier_plan', details: { plan_id: id, course_ids, count: course_ids.length } });
return R.success(res, 'Plan courses updated.');
} catch (err) {
console.error('[ADMIN][SYNC PLAN COURSES]', err);
return R.error(res, 'Could not update plan courses.', 500);
}
};
// ─── PLAN UNITS ───────────────────────────────────────────────────────────────
// Mirrors PLAN COURSES above — bundling/display only, not an access-control
// mechanism (see canAccessUnit in controllers/client/courses.controller.js).
exports.getPlanUnits = async (req, res) => {
try {
const entries = await mdl_PlanUnits.findAll({
where: { plan_id: req.params.id },
include: [{
model: Unit,
as: 'unit',
attributes: ['unit_id', 'title', 'subscription'],
}],
order: [['createdAt', 'ASC']],
});
return R.success(res, 'Plan units retrieved.', entries.map(e => e.unit));
} catch (err) {
console.error('[ADMIN][GET PLAN UNITS]', err);
return R.error(res, 'Could not retrieve plan units.', 500);
}
};
exports.syncPlanUnits = async (req, res) => {
try {
const { id } = req.params;
const { unit_ids = [] } = req.body;
if (!Array.isArray(unit_ids))
return R.error(res, 'unit_ids must be an array.', 400);
const plan = await mdl_TierPlans.findByPk(id);
if (!plan) return R.error(res, 'Plan not found.', 404);
await mdl_PlanUnits.destroy({ where: { plan_id: id } });
if (unit_ids.length) {
// 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 })),
);
}
logActivity(req.user?.user_id, 'sync_plan_units', { entityType: 'tier_plan', details: { plan_id: id, unit_ids, count: unit_ids.length } });
return R.success(res, 'Plan units updated.');
} catch (err) {
console.error('[ADMIN][SYNC PLAN UNITS]', err);
return R.error(res, 'Could not update plan units.', 500);
}
};
// ─── PLAN LESSONS ─────────────────────────────────────────────────────────────
// Mirrors PLAN COURSES above — bundling/display only, not an access-control
// mechanism (see canAccessLesson in controllers/client/courses.controller.js).
exports.getPlanLessons = async (req, res) => {
try {
const entries = await mdl_PlanLessons.findAll({
where: { plan_id: req.params.id },
include: [{
model: Lesson,
as: 'lesson',
attributes: ['lesson_id', 'title', 'subscription'],
}],
order: [['createdAt', 'ASC']],
});
return R.success(res, 'Plan lessons retrieved.', entries.map(e => e.lesson));
} catch (err) {
console.error('[ADMIN][GET PLAN LESSONS]', err);
return R.error(res, 'Could not retrieve plan lessons.', 500);
}
};
exports.syncPlanLessons = async (req, res) => {
try {
const { id } = req.params;
const { lesson_ids = [] } = req.body;
if (!Array.isArray(lesson_ids))
return R.error(res, 'lesson_ids must be an array.', 400);
const plan = await mdl_TierPlans.findByPk(id);
if (!plan) return R.error(res, 'Plan not found.', 404);
await mdl_PlanLessons.destroy({ where: { plan_id: id } });
if (lesson_ids.length) {
// 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 })),
);
}
logActivity(req.user?.user_id, 'sync_plan_lessons', { entityType: 'tier_plan', details: { plan_id: id, lesson_ids, count: lesson_ids.length } });
return R.success(res, 'Plan lessons updated.');
} catch (err) {
console.error('[ADMIN][SYNC PLAN LESSONS]', err);
return R.error(res, 'Could not update plan lessons.', 500);
}
};