mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
424 lines
16 KiB
JavaScript
424 lines
16 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* 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 } = 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 { Course } = require('../../models/courses/courses.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 {
|
|
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 PENDING_PAYMENT_EXPIRY_MINUTES = 60;
|
|
|
|
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',
|
|
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);
|
|
}
|
|
};
|
|
|
|
exports.createPlan = async (req, res) => {
|
|
try {
|
|
const { tier_category_id, label, duration_days, price, currency } = req.body;
|
|
if (!tier_category_id || !label || !duration_days || !price)
|
|
return R.error(res, 'tier_category_id, label, duration_days, and price are required.', 400);
|
|
|
|
const category = await mdl_TierCategories.findByPk(tier_category_id);
|
|
if (!category || !category.is_active)
|
|
return R.error(res, 'Tier category not found or inactive.', 404);
|
|
|
|
if (category.is_default)
|
|
return R.error(res, 'Plans cannot be created under the default (Free) tier. Free access is automatic.', 400);
|
|
|
|
const plan = await mdl_TierPlans.create({
|
|
tier_category_id: category.tier_category_id,
|
|
tier: category.slug,
|
|
label, duration_days, price, currency,
|
|
});
|
|
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', 'duration_days', 'price', 'currency', 'is_active', 'tier_category_id'];
|
|
const updates = {};
|
|
for (const k of allowed) {
|
|
if (req.body[k] !== undefined) updates[k] = req.body[k];
|
|
}
|
|
|
|
// 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, 'Tier category not found or inactive.', 404);
|
|
if (category.is_default)
|
|
return R.error(res, 'Plans cannot be moved to the Free tier 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.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 });
|
|
await plan.destroy();
|
|
logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
|
|
return R.success(res, 'Plan archived successfully.');
|
|
} 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 }, { where: { plan_id: activeIds } });
|
|
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
|
|
|
|
logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length } });
|
|
return R.success(res, `${activeIds.length} plan(s) archived successfully.`, {
|
|
archived_ids: activeIds,
|
|
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
|
});
|
|
} 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.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 tiers retrieved.', tiers);
|
|
} catch (err) {
|
|
console.error('[ADMIN][GET USER TIERS]', err);
|
|
return R.error(res, 'Could not retrieve user tiers.', 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;
|
|
|
|
await mdl_UserTiers.update(
|
|
{ status: 'expired' },
|
|
{ where: { user_id, status: 'active' } }
|
|
);
|
|
|
|
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,
|
|
});
|
|
|
|
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) {
|
|
console.error('[ADMIN][GRANT TIER]', err);
|
|
return R.error(res, 'Could not grant tier.', 500);
|
|
}
|
|
};
|
|
|
|
exports.revokeTier = async (req, res) => {
|
|
try {
|
|
const tierRecord = await mdl_UserTiers.findByPk(req.params.tid);
|
|
if (!tierRecord) return R.error(res, 'Tier record not found.', 404);
|
|
if (tierRecord.status !== 'active') return R.error(res, 'Tier is not active.', 400);
|
|
|
|
await tierRecord.update({
|
|
status: 'revoked',
|
|
revoked_by: req.user.user_id,
|
|
revoked_at: new Date(),
|
|
});
|
|
|
|
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, 'Tier revoked. User downgraded to free.');
|
|
} catch (err) {
|
|
console.error('[ADMIN][REVOKE TIER]', err);
|
|
return R.error(res, 'Could not revoke tier.', 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) {
|
|
// 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 } });
|
|
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);
|
|
}
|
|
};
|