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