Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-13 19:58:52 +08:00
parent 9018d6d158
commit 2e9c2ad43f
23 changed files with 954 additions and 287 deletions
@@ -137,9 +137,9 @@ exports.getCourseReadingProgress = async (req, res) => {
return { return {
...entry, ...entry,
user: { user: {
email: u?.email ?? null, email: u?.email ?? null,
full_name: u?.personal_info?.name?.full_name ?? null, full_name: u?.personal_info?.name?.full_name ?? null,
avatar_url: avatar?.url ?? null, avatar_stream_token: avatar?.stream_token ?? null,
}, },
units_total, units_total,
lessons_total, lessons_total,
+9 -9
View File
@@ -2614,13 +2614,13 @@ exports.syncInstructors = async (req, res) => {
// ─── COMPLETIONS HELPERS ────────────────────────────────────────────────────── // ─── COMPLETIONS HELPERS ──────────────────────────────────────────────────────
async function extractUserInfo(user) { async function extractUserInfo(user) {
if (!user) return { full_name: null, email: null, avatar_url: null, deleted: false }; if (!user) return { full_name: null, email: null, avatar_stream_token: null, deleted: false };
const avatar = await resolveAvatarUrl(user.personal_info?.avatar); const avatar = await resolveAvatarUrl(user.personal_info?.avatar);
return { return {
full_name: user.personal_info?.name?.full_name ?? null, full_name: user.personal_info?.name?.full_name ?? null,
email: user.email ?? null, email: user.email ?? null,
avatar_url: avatar?.url ?? null, avatar_stream_token: avatar?.stream_token ?? null,
deleted: !!user.deletedAt, deleted: !!user.deletedAt,
}; };
} }
@@ -2629,12 +2629,12 @@ async function groupByUser(attempts) {
for (const a of attempts) { for (const a of attempts) {
const uid = String(a.user_id); const uid = String(a.user_id);
if (!map.has(uid)) { if (!map.has(uid)) {
const { full_name, email, avatar_url, deleted } = await extractUserInfo(a.user); const { full_name, email, avatar_stream_token, deleted } = await extractUserInfo(a.user);
map.set(uid, { map.set(uid, {
user_id: a.user_id, user_id: a.user_id,
full_name, full_name,
email, email,
avatar_url, avatar_stream_token,
deleted, deleted,
attempt_count: 0, attempt_count: 0,
best_score: 0, best_score: 0,
@@ -2757,7 +2757,7 @@ exports.getAssessmentSessions = async (req, res) => {
const rows = await Promise.all(sessions.map(async (s) => { const rows = await Promise.all(sessions.map(async (s) => {
const j = s.toJSON(); const j = s.toJSON();
const { full_name, email, avatar_url, deleted } = await extractUserInfo(j.user); const { full_name, email, avatar_stream_token, deleted } = await extractUserInfo(j.user);
const time_spent_seconds = j.status !== 'in_progress' && j.started_at const time_spent_seconds = j.status !== 'in_progress' && j.started_at
? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000) ? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000)
: null; : null;
@@ -2766,7 +2766,7 @@ exports.getAssessmentSessions = async (req, res) => {
user_id: j.user_id, user_id: j.user_id,
full_name, full_name,
email, email,
avatar_url, avatar_stream_token,
deleted, deleted,
status: j.status, status: j.status,
started_at: j.started_at, started_at: j.started_at,
-10
View File
@@ -79,21 +79,11 @@ function makeProductHandlers(purchasable_type, paramName) {
} }
const courseProductHandlers = makeProductHandlers('course', 'courseId'); const courseProductHandlers = makeProductHandlers('course', 'courseId');
const unitProductHandlers = makeProductHandlers('unit', 'unitId');
const lessonProductHandlers = makeProductHandlers('lesson', 'lessonId');
exports.getCourseProduct = courseProductHandlers.get; exports.getCourseProduct = courseProductHandlers.get;
exports.upsertCourseProduct = courseProductHandlers.upsert; exports.upsertCourseProduct = courseProductHandlers.upsert;
exports.removeCourseProduct = courseProductHandlers.remove; exports.removeCourseProduct = courseProductHandlers.remove;
exports.getUnitProduct = unitProductHandlers.get;
exports.upsertUnitProduct = unitProductHandlers.upsert;
exports.removeUnitProduct = unitProductHandlers.remove;
exports.getLessonProduct = lessonProductHandlers.get;
exports.upsertLessonProduct = lessonProductHandlers.upsert;
exports.removeLessonProduct = lessonProductHandlers.remove;
// ─── CATEGORIES (per course) ────────────────────────────────────────────────── // ─── CATEGORIES (per course) ──────────────────────────────────────────────────
exports.getCourseCategories = async (req, res) => { exports.getCourseCategories = async (req, res) => {
+9 -28
View File
@@ -15,10 +15,10 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
'use strict'; 'use strict';
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { uploadFile, deleteFile } = require('../../services/s3.service'); const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service');
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util'); const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
// ─── GET own profile ─────────────────────────────────────────────────────────── // ─── GET own profile ───────────────────────────────────────────────────────────
@@ -69,28 +69,9 @@ exports.uploadAvatar = async (req, res) => {
const user = await mdl_Users.findByPk(req.user.user_id); const user = await mdl_Users.findByPk(req.user.user_id);
// Remove old avatar from S3 before replacing const avatarMeta = await replaceUserAvatar(user, req.file);
const oldKey = user.personal_info?.avatar?.uuid;
if (oldKey) await deleteFile(oldKey).catch(() => {});
const { url, uuid } = await uploadFile({
buffer: req.file.buffer,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
ownerType: 'avatar',
});
const merged = {
...(user.personal_info || {}),
avatar: {
url,
uuid,
name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
},
};
const merged = { ...(user.personal_info || {}), avatar: avatarMeta };
await user.update({ personal_info: merged }); await user.update({ personal_info: merged });
const updated = await mdl_Users.findByPk(req.user.user_id, { const updated = await mdl_Users.findByPk(req.user.user_id, {
@@ -98,6 +79,7 @@ exports.uploadAvatar = async (req, res) => {
}); });
return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated)); return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated));
} catch (err) { } catch (err) {
if (err.status === 400) return R.error(res, err.message, 400);
console.error('[ADMIN] uploadAvatar error:', err); console.error('[ADMIN] uploadAvatar error:', err);
return R.error(res, 'Avatar upload failed.', 500); return R.error(res, 'Avatar upload failed.', 500);
} }
@@ -108,16 +90,15 @@ exports.uploadAvatar = async (req, res) => {
exports.deleteAvatar = async (req, res) => { exports.deleteAvatar = async (req, res) => {
try { try {
const user = await mdl_Users.findByPk(req.user.user_id); const user = await mdl_Users.findByPk(req.user.user_id);
const key = user.personal_info?.avatar?.uuid;
if (!key) return R.error(res, 'No avatar to remove.', 404);
await deleteFile(key).catch(() => {}); await removeUserAvatar(user);
const merged = { ...(user.personal_info || {}), avatar: null }; const merged = { ...(user.personal_info || {}), avatar: null };
await user.update({ personal_info: merged }); await user.update({ personal_info: merged });
return R.success(res, 'Avatar removed.'); return R.success(res, 'Avatar removed.');
} catch (err) { } catch (err) {
if (err.status === 404) return R.error(res, err.message, 404);
console.error('[ADMIN] deleteAvatar error:', err); console.error('[ADMIN] deleteAvatar error:', err);
return R.error(res, 'Could not remove avatar.', 500); return R.error(res, 'Could not remove avatar.', 500);
} }
+12 -17
View File
@@ -29,7 +29,7 @@ const { paginate } = require('../../utils/paginate.util');
const { getFieldValues } = require('../../utils/fieldValues.util'); const { getFieldValues } = require('../../utils/fieldValues.util');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const { snapshotPlanGrants } = require('../../services/tierGrants.service'); const { snapshotPlanGrants } = require('../../services/tierGrants.service');
const { revokePlanSubscriberAccess } = require('../../services/planAccess.service'); const { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk } = require('../../services/planAccess.service');
const { const {
excludeAttributes: plansExclude, excludeAttributes: plansExclude,
@@ -244,16 +244,14 @@ exports.bulkArchivePlans = async (req, res) => {
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } }); await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
// Archiving always force-revokes current subscribers' access (no refund) — // Archiving always force-revokes current subscribers' access (no refund) —
// each plan fires its own tier_plan_access_revoked (needs each plan's own // one batched call across all selected plans (each still fires its own
// label), not the old batched "access unaffected" tier_plan_archived notice. // 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; let revoked_user_count = 0;
for (const p of activePlans) { try {
try { ({ revoked_user_count } = await revokePlanSubscriberAccessBulk(activePlans, req.user?.user_id ?? null));
const { revoked_user_count: c } = await revokePlanSubscriberAccess(p, req.user?.user_id ?? null); } catch (revokeErr) {
revoked_user_count += c; console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr);
} 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 } }); logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length, revoked_user_count } });
@@ -380,13 +378,10 @@ exports.bulkPermanentlyDeletePlans = async (req, res) => {
const archivedIds = archivedPlans.map((p) => p.plan_id); const archivedIds = archivedPlans.map((p) => p.plan_id);
// Same reasoning as the single-delete path above: revoke any remaining // Same reasoning as the single-delete path above: revoke any remaining
// active subscribers per plan (each needs its own label for the // active subscribers (each plan still gets its own label on the
// notification/email) before the records are gone for good. // notification/email) before the records are gone for good — batched in
let revoked_user_count = 0; // one call across all selected plans instead of one call per plan.
for (const p of archivedPlans) { const { revoked_user_count } = await revokePlanSubscriberAccessBulk(archivedPlans, req.user?.user_id ?? null);
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 // 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 // by default) — plans can't be force-destroyed while payment rows still
@@ -123,12 +123,12 @@ async function formatRow(row) {
const info = r.user?.personal_info; const info = r.user?.personal_info;
const avatar = await resolveAvatarUrl(info?.avatar); const avatar = await resolveAvatarUrl(info?.avatar);
return { return {
activity_id: r.activity_id, activity_id: r.activity_id,
user_id: r.user_id, user_id: r.user_id,
email: r.user?.email ?? null, email: r.user?.email ?? null,
full_name: info?.name?.full_name ?? null, full_name: info?.name?.full_name ?? null,
avatar_url: avatar?.url ?? null, avatar_stream_token: avatar?.stream_token ?? null,
acc_type: r.user?.acc_type ?? null, acc_type: r.user?.acc_type ?? null,
action: r.action, action: r.action,
entity_type: r.entity_type, entity_type: r.entity_type,
entity_id: r.entity_id, entity_id: r.entity_id,
+2 -1
View File
@@ -28,6 +28,7 @@ const R = require('../../utils/response.util');
const { paginate } = require('../../utils/paginate.util'); const { paginate } = require('../../utils/paginate.util');
const { enrichPersonalInfo } = require('../../utils/personalInfo.util'); const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
const { getFieldValues } = require("../../utils/fieldValues.util"); const { getFieldValues } = require("../../utils/fieldValues.util");
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: userComputed } = require('../../models/users/users.attributes'); const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: userComputed } = require('../../models/users/users.attributes');
@@ -119,7 +120,7 @@ exports.getUser = async (req, res) => {
}); });
if (!user) return R.error(res, 'User not found.', 404); if (!user) return R.error(res, 'User not found.', 404);
return R.success(res, 'User retrieved.', user); return R.success(res, 'User retrieved.', await resolveUserAvatar(user));
} catch (err) { } catch (err) {
console.error('[ADMIN][GET USER]', err); console.error('[ADMIN][GET USER]', err);
return R.error(res, 'Could not retrieve user.', 500); return R.error(res, 'Could not retrieve user.', 500);
+9 -43
View File
@@ -158,8 +158,6 @@ async function canAccessUnit(user_id, unit_id) {
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] }); const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] });
if (unit?.subscription && await hasItemGrant(user_id, 'unit', unit_id)) return true; if (unit?.subscription && await hasItemGrant(user_id, 'unit', unit_id)) return true;
if (await hasActivePurchase(user_id, 'unit', unit_id)) return true;
// Only links to PUBLISHED courses count as a real course dependency — a unit // Only links to PUBLISHED courses count as a real course dependency — a unit
// whose only link is to a draft/unpublished course behaves as if it had no // whose only link is to a draft/unpublished course behaves as if it had no
// course link at all (falls through to the free/standalone branch below), // course link at all (falls through to the free/standalone branch below),
@@ -182,8 +180,6 @@ async function canAccessLesson(user_id, lesson_id) {
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] }); const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
if (lesson?.subscription && await hasItemGrant(user_id, 'lesson', lesson_id)) return true; if (lesson?.subscription && await hasItemGrant(user_id, 'lesson', lesson_id)) return true;
if (await hasActivePurchase(user_id, 'lesson', lesson_id)) return true;
const unitLinks = await UnitLesson.findAll({ where: { lesson_id }, attributes: ['unit_id'] }); const unitLinks = await UnitLesson.findAll({ where: { lesson_id }, attributes: ['unit_id'] });
if (!unitLinks.length) return !lesson?.subscription || lesson.subscription === 'free'; if (!unitLinks.length) return !lesson?.subscription || lesson.subscription === 'free';
for (const link of unitLinks) { for (const link of unitLinks) {
@@ -1311,12 +1307,11 @@ exports.getLessonsByUnitUuid = async (req, res) => {
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) { if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
const first = unit.courses?.[0] ?? null; const first = unit.courses?.[0] ?? null;
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
return res.status(403).json({ return res.status(403).json({
status: "error", status: "error",
message: "You do not have access to this unit.", message: "You do not have access to this unit.",
course: first ? { title: first.title, subscription: first.subscription } : null, course: first ? { title: first.title, subscription: first.subscription } : null,
item: { uuid: unit.uuid, subscription: unit.subscription, product, has_purchased, purchase_eligible }, item: { uuid: unit.uuid, subscription: unit.subscription },
}); });
} }
@@ -1440,12 +1435,11 @@ exports.getLessonByUuid = async (req, res) => {
if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) { if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) {
const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null; const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null;
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
return res.status(403).json({ return res.status(403).json({
status: "error", status: "error",
message: "You do not have access to this lesson.", message: "You do not have access to this lesson.",
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null, course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
item: { uuid: lesson.uuid, subscription: lesson.subscription, product, has_purchased, purchase_eligible }, item: { uuid: lesson.uuid, subscription: lesson.subscription },
}); });
} }
@@ -1491,18 +1485,16 @@ exports.getLessonByUuid = async (req, res) => {
} }
}; };
// ─── CHECKOUT INFO (course/unit/lesson) ─────────────────────────────────────── // ─── CHECKOUT INFO (course) ────────────────────────────────────────────────
// Deliberately does NOT hard-403 on locked content like getCourse/ // Deliberately does NOT hard-403 on locked content like getCourse does — a
// getUnitByUuid/getLessonByUuid do — a locked-and-unpurchased item is exactly // locked-and-unpurchased course is exactly who needs to land on this page and
// who needs to land on this page and see title/description/product, so it // see title/description/product, so it can't gate on the same canAccess*()
// can't gate on the same canAccess*() check those content-serving routes use. // check those content-serving routes use. Auth-only; content stays fully
// Auth-only; content stays fully protected behind the routes above. // protected behind the routes above.
const CHECKOUT_PK = { course: "course_id", unit: "unit_id", lesson: "lesson_id" };
async function buildCheckoutInfo(user_id, purchasable_type, record) { async function buildCheckoutInfo(user_id, purchasable_type, record) {
const product = await mdl_Product.findOne({ const product = await mdl_Product.findOne({
where: { purchasable_type, purchasable_id: record[CHECKOUT_PK[purchasable_type]], is_active: true }, where: { purchasable_type, purchasable_id: record.course_id, is_active: true },
attributes: ["id", "name", "price", "currency", "access_days"], attributes: ["id", "name", "price", "currency", "access_days"],
}); });
const hasPurchase = product && await mdl_CoursePurchase.findOne({ const hasPurchase = product && await mdl_CoursePurchase.findOne({
@@ -1529,29 +1521,3 @@ exports.getCourseCheckoutInfo = async (req, res) => {
return R.error(res, "Could not retrieve checkout info.", 500); return R.error(res, "Could not retrieve checkout info.", 500);
} }
}; };
exports.getUnitCheckoutInfo = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ["unit_id", "uuid", "title", "description", "subscription"] });
if (!unit) return R.error(res, "Unit not found.", 404);
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
return R.success(res, "Checkout info retrieved.", { ...unit.toJSON(), product, has_purchased, purchase_eligible });
} catch (err) {
console.error("[CLIENT][UNITS][CHECKOUT INFO]", err);
return R.error(res, "Could not retrieve checkout info.", 500);
}
};
exports.getLessonCheckoutInfo = async (req, res) => {
try {
const { uuid } = req.params;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid", "title", "description", "subscription"] });
if (!lesson) return R.error(res, "Lesson not found.", 404);
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
return R.success(res, "Checkout info retrieved.", { ...lesson.toJSON(), product, has_purchased, purchase_eligible });
} catch (err) {
console.error("[CLIENT][LESSONS][CHECKOUT INFO]", err);
return R.error(res, "Could not retrieve checkout info.", 500);
}
};
+8 -27
View File
@@ -19,9 +19,9 @@ const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl'); const mdl_Achievements = require('../../models/users/achievements.mdl');
const trustedDevice = require('../../services/trustedDevice.service'); const trustedDevice = require('../../services/trustedDevice.service');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { uploadFile, deleteFile } = require('../../services/s3.service'); const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service');
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util'); const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
// ─── GET own profile ─────────────────────────────────────────────────────────── // ─── GET own profile ───────────────────────────────────────────────────────────
@@ -114,28 +114,9 @@ exports.uploadAvatar = async (req, res) => {
const user = await mdl_Users.findByPk(req.user.user_id); const user = await mdl_Users.findByPk(req.user.user_id);
// Remove old avatar from S3 before replacing const avatarMeta = await replaceUserAvatar(user, req.file);
const oldKey = user.personal_info?.avatar?.uuid;
if (oldKey) await deleteFile(oldKey).catch(() => {});
const { url, uuid } = await uploadFile({
buffer: req.file.buffer,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
ownerType: 'avatar',
});
const merged = {
...(user.personal_info || {}),
avatar: {
url,
uuid,
name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
},
};
const merged = { ...(user.personal_info || {}), avatar: avatarMeta };
await user.update({ personal_info: merged }); await user.update({ personal_info: merged });
const updated = await mdl_Users.findByPk(req.user.user_id, { const updated = await mdl_Users.findByPk(req.user.user_id, {
@@ -143,6 +124,7 @@ exports.uploadAvatar = async (req, res) => {
}); });
return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated)); return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated));
} catch (err) { } catch (err) {
if (err.status === 400) return R.error(res, err.message, 400);
console.error('[CLIENT] uploadAvatar error:', err); console.error('[CLIENT] uploadAvatar error:', err);
return R.error(res, 'Avatar upload failed.', 500); return R.error(res, 'Avatar upload failed.', 500);
} }
@@ -153,16 +135,15 @@ exports.uploadAvatar = async (req, res) => {
exports.deleteAvatar = async (req, res) => { exports.deleteAvatar = async (req, res) => {
try { try {
const user = await mdl_Users.findByPk(req.user.user_id); const user = await mdl_Users.findByPk(req.user.user_id);
const key = user.personal_info?.avatar?.uuid;
if (!key) return R.error(res, 'No avatar to remove.', 404);
await deleteFile(key).catch(() => {}); await removeUserAvatar(user);
const merged = { ...(user.personal_info || {}), avatar: null }; const merged = { ...(user.personal_info || {}), avatar: null };
await user.update({ personal_info: merged }); await user.update({ personal_info: merged });
return R.success(res, 'Avatar removed.'); return R.success(res, 'Avatar removed.');
} catch (err) { } catch (err) {
if (err.status === 404) return R.error(res, err.message, 404);
console.error('[CLIENT] deleteAvatar error:', err); console.error('[CLIENT] deleteAvatar error:', err);
return R.error(res, 'Could not remove avatar.', 500); return R.error(res, 'Could not remove avatar.', 500);
} }
-50
View File
@@ -30,12 +30,9 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
"use strict"; "use strict";
const { Op } = require("sequelize");
const R = require("../../utils/response.util"); const R = require("../../utils/response.util");
const logActivity = require("../../utils/logActivity.util"); const logActivity = require("../../utils/logActivity.util");
const sequelize = require("../../config/db.config"); const sequelize = require("../../config/db.config");
const mdl_Product = require("../../models/courses/products.mdl");
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const { const {
Unit, Lesson, Unit, Lesson,
@@ -64,35 +61,6 @@ function sanitizeQuestions(questions = []) {
}); });
} }
// Batch-fetch active product listings + this user's completed/unexpired
// purchases for a set of standalone targets (unit or lesson), same shape as
// the courses.controller.js equivalent — used by getUnits/getLessons below so
// the browse-list Buy button has price data without an N+1 query per row.
async function attachProducts(user_id, purchasable_type, ids) {
if (!ids.length) return { productById: new Map(), purchasedIds: new Set() };
const products = await mdl_Product.findAll({
where: { purchasable_type, purchasable_id: { [Op.in]: ids }, is_active: true },
attributes: ["id", "name", "price", "currency", "access_days", "is_active", "purchasable_id"],
});
const productById = new Map(products.map((p) => [String(p.purchasable_id), p]));
const productIds = products.map((p) => p.id);
const purchases = productIds.length ? await mdl_CoursePurchase.findAll({
where: {
user_id, product_id: { [Op.in]: productIds }, status: "completed",
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
attributes: ["product_id"],
}) : [];
const purchasedProductIds = new Set(purchases.map((p) => String(p.product_id)));
const purchasedIds = new Set(
products.filter((p) => purchasedProductIds.has(String(p.id))).map((p) => String(p.purchasable_id))
);
return { productById, purchasedIds };
}
// ─── UNIT LIBRARY (learner view) ────────────────────────────────────────────── // ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
// Client-side Units/Lessons browsing shows ALL content, bound to a course or // Client-side Units/Lessons browsing shows ALL content, bound to a course or
@@ -136,8 +104,6 @@ exports.getUnits = async (req, res) => {
coursesByUnit.set(row.unit_id, list); coursesByUnit.set(row.unit_id, list);
} }
const { productById, purchasedIds } = await attachProducts(req.user.user_id, "unit", unitIds);
// is_locked mirrors canAccessUnit: a unit with its own subscription or at // is_locked mirrors canAccessUnit: a unit with its own subscription or at
// least one attached course needs an access check; a fully open standalone // least one attached course needs an access check; a fully open standalone
// unit (no subscription, no course links) is never locked. // unit (no subscription, no course links) is never locked.
@@ -146,16 +112,10 @@ exports.getUnits = async (req, res) => {
const is_locked = (row.subscription || Number(row.course_count) > 0) const is_locked = (row.subscription || Number(row.course_count) > 0)
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id)) ? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
: false; : false;
const product = productById.get(String(row.unit_id)) ?? null;
const has_purchased = purchasedIds.has(String(row.unit_id));
const purchase_eligible = true;
result.push({ result.push({
...row, ...row,
courses: coursesByUnit.get(row.unit_id) ?? [], courses: coursesByUnit.get(row.unit_id) ?? [],
is_locked, is_locked,
product,
has_purchased,
purchase_eligible,
}); });
} }
@@ -202,8 +162,6 @@ exports.getLessons = async (req, res) => {
coursesByLesson.set(row.lesson_id, list); coursesByLesson.set(row.lesson_id, list);
} }
const { productById, purchasedIds } = await attachProducts(req.user.user_id, "lesson", lessonIds);
// is_locked mirrors canAccessLesson: a lesson with its own subscription or // is_locked mirrors canAccessLesson: a lesson with its own subscription or
// at least one attached unit needs an access check; a fully open // at least one attached unit needs an access check; a fully open
// standalone lesson (no subscription, no unit links) is never locked. // standalone lesson (no subscription, no unit links) is never locked.
@@ -212,16 +170,10 @@ exports.getLessons = async (req, res) => {
const is_locked = (row.subscription || Number(row.unit_count) > 0) const is_locked = (row.subscription || Number(row.unit_count) > 0)
? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id)) ? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
: false; : false;
const product = productById.get(String(row.lesson_id)) ?? null;
const has_purchased = purchasedIds.has(String(row.lesson_id));
const purchase_eligible = true;
result.push({ result.push({
...row, ...row,
courses: coursesByLesson.get(row.lesson_id) ?? [], courses: coursesByLesson.get(row.lesson_id) ?? [],
is_locked, is_locked,
product,
has_purchased,
purchase_eligible,
}); });
} }
@@ -537,5 +489,3 @@ exports.markStandaloneLessonComplete = async (req, res) => {
exports.getUnitByUuid = coursesCtrl.getUnitByUuid; exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid; exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
exports.getLessonByUuid = coursesCtrl.getLessonByUuid; exports.getLessonByUuid = coursesCtrl.getLessonByUuid;
exports.getUnitCheckoutInfo = coursesCtrl.getUnitCheckoutInfo;
exports.getLessonCheckoutInfo = coursesCtrl.getLessonCheckoutInfo;
+7 -10
View File
@@ -2,11 +2,11 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config'); const sequelize = require('../../config/db.config');
const { Course } = require('./courses.mdl'); const { Course } = require('./courses.mdl');
const Unit = require('./units.mdl');
const Lesson = require('./lessons.mdl');
// Polymorphic target — a product is sold against exactly one Course, Unit, or // Polymorphic target — a product is sold against a Course (purchasable_type +
// Lesson (purchasable_type + purchasable_id), not just courses. See // purchasable_id). Unit/Lesson individual purchase was removed; the schema
// stays polymorphic (purchasable_type/purchasable_id, not a course_id FK)
// since existing rows and course_purchases still key off it. See
// utils/purchasable.util.js for the type -> model/checkout-path resolver used // utils/purchasable.util.js for the type -> model/checkout-path resolver used
// by every consumer (access checks, admin CRUD, checkout). // by every consumer (access checks, admin CRUD, checkout).
// //
@@ -30,12 +30,9 @@ const mdl_Product = sequelize.define('Product', {
paranoid: true, paranoid: true,
}); });
// Scoped hasOne per target type — Sequelize automatically adds the matching // Scoped hasOne — Sequelize automatically adds the matching purchasable_type
// purchasable_type filter to the join, so existing // filter to the join, so existing `include: [{ model: mdl_Product, as:
// `include: [{ model: mdl_Product, as: 'product' }]` call sites on Course // 'product' }]` call sites on Course keep working unchanged.
// keep working unchanged.
Course.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'course' }, as: 'product' }); Course.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'course' }, as: 'product' });
Unit.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'unit' }, as: 'product' });
Lesson.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'lesson' }, as: 'product' });
module.exports = mdl_Product; module.exports = mdl_Product;
+610 -5
View File
@@ -38,6 +38,7 @@
"rate-limit-redis": "^4.0.0", "rate-limit-redis": "^4.0.0",
"redis": "^4.6.7", "redis": "^4.6.7",
"sequelize": "^6.32.1", "sequelize": "^6.32.1",
"sharp": "^0.35.3",
"ua-parser-js": "^2.0.10", "ua-parser-js": "^2.0.10",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
@@ -992,10 +993,8 @@
"version": "1.11.2", "version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -1012,6 +1011,554 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -3304,6 +3851,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/detect-newline": { "node_modules/detect-newline": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -6827,9 +7383,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -7016,6 +7572,55 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+1
View File
@@ -45,6 +45,7 @@
"rate-limit-redis": "^4.0.0", "rate-limit-redis": "^4.0.0",
"redis": "^4.6.7", "redis": "^4.6.7",
"sequelize": "^6.32.1", "sequelize": "^6.32.1",
"sharp": "^0.35.3",
"ua-parser-js": "^2.0.10", "ua-parser-js": "^2.0.10",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
-10
View File
@@ -7,16 +7,6 @@ router.get ('/courses/:courseId/product', ctrl.getCourseProduct);
router.put ('/courses/:courseId/product', ctrl.upsertCourseProduct); router.put ('/courses/:courseId/product', ctrl.upsertCourseProduct);
router.delete('/courses/:courseId/product', ctrl.removeCourseProduct); router.delete('/courses/:courseId/product', ctrl.removeCourseProduct);
// Product per standalone unit
router.get ('/units/:unitId/product', ctrl.getUnitProduct);
router.put ('/units/:unitId/product', ctrl.upsertUnitProduct);
router.delete('/units/:unitId/product', ctrl.removeUnitProduct);
// Product per standalone lesson
router.get ('/lessons/:lessonId/product', ctrl.getLessonProduct);
router.put ('/lessons/:lessonId/product', ctrl.upsertLessonProduct);
router.delete('/lessons/:lessonId/product', ctrl.removeLessonProduct);
// Categories per course // Categories per course
router.get ('/courses/:courseId/categories', ctrl.getCourseCategories); router.get ('/courses/:courseId/categories', ctrl.getCourseCategories);
router.post ('/courses/:courseId/categories', ctrl.syncCourseCategories); router.post ('/courses/:courseId/categories', ctrl.syncCourseCategories);
-1
View File
@@ -16,7 +16,6 @@ const router = express.Router();
const ctrl = require('../../controllers/client/units.controller'); const ctrl = require('../../controllers/client/units.controller');
router.get('/', ctrl.getLessons); router.get('/', ctrl.getLessons);
router.get('/:uuid/checkout-info', ctrl.getLessonCheckoutInfo);
router.get('/:uuid', ctrl.getLessonByUuid); router.get('/:uuid', ctrl.getLessonByUuid);
router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress); router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress);
router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress); router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress);
-1
View File
@@ -19,7 +19,6 @@ const ctrl = require('../../controllers/client/units.controller');
router.get('/', ctrl.getUnits); router.get('/', ctrl.getUnits);
router.get('/:uuid/lessons', ctrl.getLessonsByUnitUuid); router.get('/:uuid/lessons', ctrl.getLessonsByUnitUuid);
router.get('/:uuid/quiz', ctrl.getUnitQuiz); router.get('/:uuid/quiz', ctrl.getUnitQuiz);
router.get('/:uuid/checkout-info', ctrl.getUnitCheckoutInfo);
router.patch('/:uuid/quiz/:quizId/draft', ctrl.saveUnitQuizDraft); router.patch('/:uuid/quiz/:quizId/draft', ctrl.saveUnitQuizDraft);
router.post('/:uuid/quiz/:quizId/submit', ctrl.submitUnitQuiz); router.post('/:uuid/quiz/:quizId/submit', ctrl.submitUnitQuiz);
router.get('/:uuid', ctrl.getUnitByUuid); router.get('/:uuid', ctrl.getUnitByUuid);
+6
View File
@@ -19,6 +19,12 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
require('dotenv').config(); require('dotenv').config();
// Local-dev-only: stop the process on wake from laptop sleep/idle instead of
// letting node-cron dump a "missed execution" warning per elapsed tick. Must
// start ticking as early as possible so it wins the race against node-cron's
// own heartbeat once cron jobs are registered below. No-ops in production.
require('./utils/suspendGuard.util').startSuspendGuard();
// Force IPv4-only outbound connections. Hosts that resolve AAAA records but // Force IPv4-only outbound connections. Hosts that resolve AAAA records but
// have no working IPv6 route (e.g. to Google's OAuth endpoints) hit ENETUNREACH // have no working IPv6 route (e.g. to Google's OAuth endpoints) hit ENETUNREACH
// on the v6 attempt — and Node's dual-stack "Happy Eyeballs" connector // on the v6 attempt — and Node's dual-stack "Happy Eyeballs" connector
+73
View File
@@ -0,0 +1,73 @@
/***********************************************************************************************************************************************************************
* File Name: avatar.service.js
* Type of Program: Service
* Description: Shared avatar processing/orchestration for admin + client self-profile.
* Server-side authoritative resize — every avatar is normalized to a fixed
* 200x200 JPEG regardless of what the client sends, so browser-side cropping
* (see AvatarUploadDialog.jsx) is a UX convenience, not the enforcement point.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 10, 2026
***********************************************************************************************************************************************************************/
'use strict';
const sharp = require('sharp');
const { uploadFile, deleteFile } = require('./s3.service');
const AVATAR_SIZE = 200;
const JPEG_QUALITY = 90;
// ─── resizeAvatarBuffer ─────────────────────────────────────────────────────────
// Normalizes any accepted input (JPEG/PNG/WebP/GIF) into a fixed 200x200 JPEG.
// GIF animation and PNG transparency are intentionally dropped — avatars render
// in an opaque round mask, so a single static frame is all that's ever shown.
async function resizeAvatarBuffer(buffer) {
try {
return await sharp(buffer)
.rotate() // respect EXIF orientation before crop
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'centre' })
.jpeg({ quality: JPEG_QUALITY })
.toBuffer();
} catch (err) {
throw Object.assign(new Error('Could not process the uploaded image.'), { status: 400, cause: err });
}
}
// ─── replaceUserAvatar ──────────────────────────────────────────────────────────
// Deletes the old S3 object (if any), resizes the new upload, stores it, and
// returns the avatar metadata object. Does not persist to the user row —
// callers own that so they can merge it into personal_info their own way.
async function replaceUserAvatar(user, file) {
const oldKey = user.personal_info?.avatar?.uuid;
if (oldKey) await deleteFile(oldKey).catch(() => {});
const resized = await resizeAvatarBuffer(file.buffer);
const { url, uuid } = await uploadFile({
buffer: resized,
originalname: 'avatar.jpg',
mimetype: 'image/jpeg',
ownerType: 'avatar',
});
return {
url,
uuid,
name: file.originalname,
mime_type: 'image/jpeg',
size: resized.length,
};
}
// ─── removeUserAvatar ───────────────────────────────────────────────────────────
async function removeUserAvatar(user) {
const key = user.personal_info?.avatar?.uuid;
if (!key) throw Object.assign(new Error('No avatar to remove.'), { status: 404 });
await deleteFile(key).catch(() => {});
}
module.exports = { resizeAvatarBuffer, replaceUserAvatar, removeUserAvatar };
+21 -10
View File
@@ -69,21 +69,31 @@ function resolveIp(req) {
return normalizeIp(raw); return normalizeIp(raw);
} }
function signToken(asset, userId, ip) { // ─── signMediaToken ─────────────────────────────────────────────────────────────
//
// Low-level JWT signer shared by every media-token caller (asset previews here,
// avatar resolution in utils/resolveAvatar.util.js) so the secret-resolution +
// payload shape only lives in one place. `asset_id`/`user_id`/`ip` are optional —
// omitting `ip` means the stream endpoint's IP-pin check is skipped for that token.
function signMediaToken({ asset_id, storage_key, file_type, mime_type, user_id, ip, expiresIn = TOKEN_TTL_SEC }) {
return jwt.sign( return jwt.sign(
{ { asset_id, user_id, storage_key, file_type, mime_type, ip },
asset_id: asset.asset_id,
user_id: userId,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip,
},
MEDIA_SECRET, MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC } { expiresIn }
); );
} }
function signToken(asset, userId, ip) {
return signMediaToken({
asset_id: asset.asset_id,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
user_id: userId,
ip,
});
}
// ─── issueForAsset ───────────────────────────────────────────────────────────── // ─── issueForAsset ─────────────────────────────────────────────────────────────
// //
// Returns { token, thumbnail_url } for an S3 asset, minting + caching on first // Returns { token, thumbnail_url } for an S3 asset, minting + caching on first
@@ -114,4 +124,5 @@ module.exports = {
SUPPORTED_TYPES, SUPPORTED_TYPES,
resolveIp, resolveIp,
issueForAsset, issueForAsset,
signMediaToken,
}; };
+52 -21
View File
@@ -20,14 +20,22 @@ const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
const { sendEmail } = require('./email.service'); const { sendEmail } = require('./email.service');
const { fmtDate } = require('../utils/datetime.util'); const { fmtDate } = require('../utils/datetime.util');
// Revokes every active user_tiers row tied to `plan`, replicating revokeTier's // Revokes every active user_tiers row tied to any of `plans`, replicating
// per-user "auto-downgrade to Free if no other active tier remains" rule // revokeTier's per-user "auto-downgrade to Free if no other active tier
// (controllers/admin/tiers.controller.js) — a user can hold more than one // remains" rule (controllers/admin/tiers.controller.js) — a user can hold
// concurrently-active plan, so this can't be a blanket status update. // more than one concurrently-active plan, so this can't be a blanket status
async function revokePlanSubscriberAccess(plan, revokedByUserId) { // update. Batched into flat, count-independent queries (no per-user or
// per-plan loop hitting the DB) so this scales to any number of affected
// plans/subscribers in a fixed number of round trips.
async function revokePlanSubscriberAccessBulk(plans, revokedByUserId) {
if (!plans.length) return { revoked_user_count: 0 };
const planIds = plans.map((p) => p.plan_id);
const labelByPlanId = new Map(plans.map((p) => [String(p.plan_id), p.label]));
const activeRows = await mdl_UserTiers.findAll({ const activeRows = await mdl_UserTiers.findAll({
where: { plan_id: plan.plan_id, status: 'active' }, where: { plan_id: planIds, status: 'active' },
attributes: ['tier_id', 'user_id'], attributes: ['tier_id', 'user_id', 'plan_id'],
}); });
if (!activeRows.length) return { revoked_user_count: 0 }; if (!activeRows.length) return { revoked_user_count: 0 };
@@ -40,10 +48,19 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
{ where: { tier_id: tierIds } }, { where: { tier_id: tierIds } },
); );
for (const user_id of userIds) { // One grouped query replaces a per-user COUNT: finds everyone who still
const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } }); // holds another active tier after the revoke above.
if (remainingActive === 0) { const stillActiveRows = await mdl_UserTiers.findAll({
await mdl_UserTiers.create({ where: { user_id: userIds, status: 'active' },
attributes: ['user_id'],
group: ['user_id'],
});
const stillActiveUserIds = new Set(stillActiveRows.map((r) => String(r.user_id)));
const usersToDowngrade = userIds.filter((user_id) => !stillActiveUserIds.has(user_id));
if (usersToDowngrade.length) {
await mdl_UserTiers.bulkCreate(
usersToDowngrade.map((user_id) => ({
user_id, user_id,
tier: 'free', tier: 'free',
status: 'active', status: 'active',
@@ -51,16 +68,23 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
expires_at: null, expires_at: null,
granted_by: revokedByUserId, granted_by: revokedByUserId,
notes: 'Auto-downgrade after plan access was force-revoked.', notes: 'Auto-downgrade after plan access was force-revoked.',
}); })),
} );
} }
// One row per (user, plan) relationship revoked — a user in two of the
// selected plans gets two notices/emails, one per plan label.
const revokedPairs = [...new Map(activeRows.map((r) => [`${r.user_id}:${r.plan_id}`, r])).values()];
try { try {
const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.build({ label: plan.label, planId: plan.plan_id }); const notifications = revokedPairs.map((r) => {
await UserNotification.bulkCreate( const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.build({
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })), label: labelByPlanId.get(String(r.plan_id)),
{ validate: false }, planId: r.plan_id,
); });
return { user_id: String(r.user_id), ...notify, seen: false, createdAt: now, updatedAt: now };
});
await UserNotification.bulkCreate(notifications, { validate: false });
} catch (notifyErr) { } catch (notifyErr) {
console.error('[PLAN ACCESS REVOKE][NOTIFY]', notifyErr); console.error('[PLAN ACCESS REVOKE][NOTIFY]', notifyErr);
} }
@@ -70,13 +94,16 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
where: { user_id: userIds }, where: { user_id: userIds },
attributes: ['user_id', 'email', 'personal_info'], attributes: ['user_id', 'email', 'personal_info'],
}); });
const usersById = new Map(users.map((u) => [String(u.user_id), u]));
const dateStr = fmtDate(now); const dateStr = fmtDate(now);
for (const u of users) { for (const r of revokedPairs) {
const u = usersById.get(String(r.user_id));
if (!u) continue;
const name = u.personal_info?.name?.full_name ?? 'there'; const name = u.personal_info?.name?.full_name ?? 'there';
sendEmail({ sendEmail({
to: u.email, to: u.email,
type: 'TIER_ACCESS_REVOKED', type: 'TIER_ACCESS_REVOKED',
data: { name, label: plan.label, date: dateStr }, data: { name, label: labelByPlanId.get(String(r.plan_id)), date: dateStr },
}).catch((emailErr) => console.error('[PLAN ACCESS REVOKE][EMAIL]', emailErr)); }).catch((emailErr) => console.error('[PLAN ACCESS REVOKE][EMAIL]', emailErr));
} }
} catch (emailBatchErr) { } catch (emailBatchErr) {
@@ -86,4 +113,8 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
return { revoked_user_count: userIds.length }; return { revoked_user_count: userIds.length };
} }
module.exports = { revokePlanSubscriberAccess }; async function revokePlanSubscriberAccess(plan, revokedByUserId) {
return revokePlanSubscriberAccessBulk([plan], revokedByUserId);
}
module.exports = { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk };
+52 -16
View File
@@ -10,8 +10,7 @@
jest.mock('../../models/tiers/user_tiers.mdl', () => ({ jest.mock('../../models/tiers/user_tiers.mdl', () => ({
findAll: jest.fn(), findAll: jest.fn(),
update: jest.fn(), update: jest.fn(),
count: jest.fn(), bulkCreate: jest.fn(),
create: jest.fn(),
})); }));
jest.mock('../../models/users/users.mdl', () => ({ findAll: jest.fn() })); jest.mock('../../models/users/users.mdl', () => ({ findAll: jest.fn() }));
jest.mock('../../models/notifications/user_notification.mdl', () => ({ bulkCreate: jest.fn() })); jest.mock('../../models/notifications/user_notification.mdl', () => ({ bulkCreate: jest.fn() }));
@@ -22,7 +21,7 @@ const mdl_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const { sendEmail } = require('../../services/email.service'); const { sendEmail } = require('../../services/email.service');
const { revokePlanSubscriberAccess } = require('../../services/planAccess.service'); const { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk } = require('../../services/planAccess.service');
function makePlan(overrides = {}) { function makePlan(overrides = {}) {
return { plan_id: 10, label: 'Premium – 1 Month', ...overrides }; return { plan_id: 10, label: 'Premium – 1 Month', ...overrides };
@@ -43,8 +42,9 @@ describe('revokePlanSubscriberAccess()', () => {
}); });
test('a user with ONLY this plan active gets auto-downgraded to Free', async () => { test('a user with ONLY this plan active gets auto-downgraded to Free', async () => {
mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 1, user_id: 5 }]); mdl_UserTiers.findAll
mdl_UserTiers.count.mockResolvedValue(0); // no other active tier remains .mockResolvedValueOnce([{ tier_id: 1, user_id: 5, plan_id: 10 }]) // active rows for the plan
.mockResolvedValueOnce([]); // grouped "still active elsewhere" check — none
mdl_Users.findAll.mockResolvedValue([{ user_id: 5, email: 'a@b.com', personal_info: { name: { full_name: 'Ana' } } }]); mdl_Users.findAll.mockResolvedValue([{ user_id: 5, email: 'a@b.com', personal_info: { name: { full_name: 'Ana' } } }]);
const result = await revokePlanSubscriberAccess(makePlan(), 99); const result = await revokePlanSubscriberAccess(makePlan(), 99);
@@ -53,28 +53,30 @@ describe('revokePlanSubscriberAccess()', () => {
expect.objectContaining({ status: 'revoked', revoked_by: 99 }), expect.objectContaining({ status: 'revoked', revoked_by: 99 }),
{ where: { tier_id: [1] } } { where: { tier_id: [1] } }
); );
expect(mdl_UserTiers.create).toHaveBeenCalledWith( expect(mdl_UserTiers.bulkCreate).toHaveBeenCalledWith([
expect.objectContaining({ user_id: '5', tier: 'free', status: 'active' }) expect.objectContaining({ user_id: '5', tier: 'free', status: 'active' }),
); ]);
expect(result).toEqual({ revoked_user_count: 1 }); expect(result).toEqual({ revoked_user_count: 1 });
}); });
test('a user with ANOTHER concurrently-active plan does NOT get downgraded', async () => { test('a user with ANOTHER concurrently-active plan does NOT get downgraded', async () => {
mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 2, user_id: 6 }]); mdl_UserTiers.findAll
mdl_UserTiers.count.mockResolvedValue(1); // still holds a different active plan .mockResolvedValueOnce([{ tier_id: 2, user_id: 6, plan_id: 10 }])
.mockResolvedValueOnce([{ user_id: 6 }]); // still holds a different active tier
mdl_Users.findAll.mockResolvedValue([{ user_id: 6, email: 'c@d.com', personal_info: {} }]); mdl_Users.findAll.mockResolvedValue([{ user_id: 6, email: 'c@d.com', personal_info: {} }]);
await revokePlanSubscriberAccess(makePlan(), 99); await revokePlanSubscriberAccess(makePlan(), 99);
expect(mdl_UserTiers.create).not.toHaveBeenCalled(); expect(mdl_UserTiers.bulkCreate).not.toHaveBeenCalled();
}); });
test('fires exactly one notification batch and one email per affected user', async () => { test('fires exactly one notification batch and one email per affected user', async () => {
mdl_UserTiers.findAll.mockResolvedValue([ mdl_UserTiers.findAll
{ tier_id: 1, user_id: 5 }, .mockResolvedValueOnce([
{ tier_id: 2, user_id: 6 }, { tier_id: 1, user_id: 5, plan_id: 10 },
]); { tier_id: 2, user_id: 6, plan_id: 10 },
mdl_UserTiers.count.mockResolvedValue(1); ])
.mockResolvedValueOnce([{ user_id: 5 }, { user_id: 6 }]);
mdl_Users.findAll.mockResolvedValue([ mdl_Users.findAll.mockResolvedValue([
{ user_id: 5, email: 'a@b.com', personal_info: {} }, { user_id: 5, email: 'a@b.com', personal_info: {} },
{ user_id: 6, email: 'c@d.com', personal_info: {} }, { user_id: 6, email: 'c@d.com', personal_info: {} },
@@ -89,3 +91,37 @@ describe('revokePlanSubscriberAccess()', () => {
expect(result).toEqual({ revoked_user_count: 2 }); expect(result).toEqual({ revoked_user_count: 2 });
}); });
}); });
describe('revokePlanSubscriberAccessBulk() — N+1 regression', () => {
test('query count stays flat regardless of plan/subscriber count (no per-user or per-plan loop)', async () => {
const plans = [
{ plan_id: 10, label: 'Premium – 1 Month' },
{ plan_id: 11, label: 'Premium – 1 Year' },
{ plan_id: 12, label: 'Basic' },
];
const activeRows = Array.from({ length: 25 }, (_, i) => ({
tier_id: i + 1,
user_id: i + 1,
plan_id: plans[i % plans.length].plan_id,
}));
mdl_UserTiers.findAll
.mockResolvedValueOnce(activeRows) // active rows across all 3 plans
.mockResolvedValueOnce([]); // grouped "still active" check — nobody else active
mdl_Users.findAll.mockResolvedValue(
activeRows.map((r) => ({ user_id: r.user_id, email: `${r.user_id}@x.com`, personal_info: {} })),
);
const result = await revokePlanSubscriberAccessBulk(plans, 99);
// Exactly 2 findAll calls total (active rows + grouped still-active check),
// 1 bulk update, 1 bulk downgrade create, 1 notification bulkCreate —
// no matter how many plans/users were involved.
expect(mdl_UserTiers.findAll).toHaveBeenCalledTimes(2);
expect(mdl_UserTiers.update).toHaveBeenCalledTimes(1);
expect(mdl_UserTiers.bulkCreate).toHaveBeenCalledTimes(1);
expect(mdl_UserTiers.bulkCreate.mock.calls[0][0]).toHaveLength(25);
expect(UserNotification.bulkCreate).toHaveBeenCalledTimes(1);
expect(UserNotification.bulkCreate.mock.calls[0][0]).toHaveLength(25);
expect(result).toEqual({ revoked_user_count: 25 });
});
});
+26 -19
View File
@@ -1,33 +1,40 @@
// utils/resolveAvatar.util.js // utils/resolveAvatar.util.js
// //
// Resolves a stored avatar into a browser-usable URL at read time. // Resolves a stored avatar into a browser-usable reference at read time.
// //
// personal_info.avatar.url is never trustworthy as stored: // personal_info.avatar is never handed to the browser as a raw S3 URL —
// - S3-stored avatars (avatar.uuid present) were historically saved with a // same protection as every other media type in this app (see
// raw, unsigned bucket URL (see s3.service.js buildPublicUrl). The current // controllers/client/media.controller.js): the browser only ever gets a
// Garage ingress (Cloudflare lane, see chibistar/Caddyfile) forwards reads // short-lived opaque stream_token, proxied through
// straight to Garage with no re-signing, and Garage rejects anonymous // GET /client/media/stream/:token, which mints the real presigned URL
// requests outright — so that stored URL 403s in the browser. Even a // server-side and pipes the bytes back. The real bucket/key/signature never
// presigned URL would go stale if persisted (getPublicUrl() expires in 4h), // reach the DOM.
// so the only correct fix is to mint a fresh one on every read from the //
// stored key (avatar.uuid), never trust what's on the row. // - S3-stored avatars (avatar.uuid present) → sign a fresh media JWT from
// the stored key on every read (never persist a token/URL on the row).
// - Google-picture avatars (reg_type: 'google', no uuid — see // - Google-picture avatars (reg_type: 'google', no uuid — see
// auth.controller.js googleCallback) are an external CDN URL and pass // auth.controller.js googleCallback) are an external CDN URL and pass
// through unchanged; there's nothing of ours to sign. // through unchanged as file_url; there's nothing of ours to sign or hide.
// //
const { getPublicUrl } = require('../services/s3.service'); const { signMediaToken } = require('../services/mediaToken.service');
async function resolveAvatarUrl(avatar) { async function resolveAvatarUrl(avatar) {
if (!avatar) return avatar ?? null; if (!avatar) return avatar ?? null;
if (!avatar.uuid) return avatar; // external URL (e.g. Google) — nothing to sign
try { if (!avatar.uuid) {
const url = await getPublicUrl(avatar.uuid); // External URL (e.g. Google) — nothing to sign, just normalize the field
return { ...avatar, url }; // name to match the { stream_token?, file_url? } shape resolveAssetSrc()
} catch (err) { // already expects for every other media payload.
console.error('[AVATAR] Failed to resolve presigned URL for', avatar.uuid, err); return { ...avatar, url: undefined, file_url: avatar.url };
return avatar; // fall back to the stored value rather than failing the whole response
} }
const token = signMediaToken({
storage_key: avatar.uuid,
file_type: 'image',
mime_type: avatar.mime_type,
});
return { ...avatar, url: undefined, file_url: undefined, stream_token: token };
} }
// Mutates-and-returns a shallow copy of a user (plain object or Sequelize // Mutates-and-returns a shallow copy of a user (plain object or Sequelize
+48
View File
@@ -0,0 +1,48 @@
/***********************************************************************************************************************************************************************
* File Name : suspendGuard.util.js
* Type : Utility
* Description : Local-dev-only watchdog that detects the host machine coming
* back from sleep/idle (laptop lid closed, suspended, etc.)
* and stops the dev server immediately.
*
* Why: when the process is frozen mid-sleep, node-cron's own
* heartbeat later finds itself hours behind schedule and logs
* a "[NODE-CRON] missed execution" warning for every tick that
* elapsed — one line per missed minute, easily hundreds after
* an overnight sleep. There's nothing to recover here (no
* request was dropped, no job silently failed); the correct
* behavior is just "the dev server wasn't meaningfully running
* during that time," so we exit instead of logging noise.
*
* Never runs when NODE_ENV=production — the droplet process
* is long-running and must never self-exit on its own.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 11, 2026
***********************************************************************************************************************************************************************/
'use strict';
const CHECK_INTERVAL_MS = 250;
const GAP_THRESHOLD_MS = 10_000; // far beyond normal event-loop jitter
function startSuspendGuard() {
if (process.env.NODE_ENV === 'production') return;
let last = Date.now();
setInterval(() => {
const now = Date.now();
const gap = now - last - CHECK_INTERVAL_MS;
last = now;
if (gap > GAP_THRESHOLD_MS) {
console.log(
`\n🛑 Dev server was asleep/idle for ~${Math.round(gap / 1000)}s (laptop suspend or similar). ` +
`Stopping instead of letting node-cron dump missed-execution warnings.\n`
);
process.exit(0);
}
}, CHECK_INTERVAL_MS).unref();
}
module.exports = { startSuspendGuard };