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:
@@ -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' } });
|
||||
|
||||
Reference in New Issue
Block a user