From 2e9c2ad43feee0ee4d7c062a974156bd384effd2 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Thu, 13 Aug 2026 19:58:52 +0800 Subject: [PATCH] fix Signed-off-by: Kenneth Obsequio --- .../course_reading_progress.controller.js | 6 +- controllers/admin/courses.controller.js | 18 +- controllers/admin/products.controller.js | 10 - controllers/admin/profile.controller.js | 37 +- controllers/admin/tiers.controller.js | 29 +- controllers/admin/user_activity.controller.js | 12 +- controllers/admin/users.controller.js | 3 +- controllers/client/courses.controller.js | 52 +- controllers/client/profile.controller.js | 35 +- controllers/client/units.controller.js | 50 -- models/courses/products.mdl.js | 17 +- package-lock.json | 615 +++++++++++++++++- package.json | 1 + routes/admin/products.routes.js | 10 - routes/client/lessons.routes.js | 1 - routes/client/units.routes.js | 1 - server.js | 6 + services/avatar.service.js | 73 +++ services/mediaToken.service.js | 31 +- services/planAccess.service.js | 73 ++- tests/services/planAccess.service.test.js | 68 +- utils/resolveAvatar.util.js | 45 +- utils/suspendGuard.util.js | 48 ++ 23 files changed, 954 insertions(+), 287 deletions(-) create mode 100644 services/avatar.service.js create mode 100644 utils/suspendGuard.util.js diff --git a/controllers/admin/course_reading_progress.controller.js b/controllers/admin/course_reading_progress.controller.js index fab4cf7..09a4fb5 100644 --- a/controllers/admin/course_reading_progress.controller.js +++ b/controllers/admin/course_reading_progress.controller.js @@ -137,9 +137,9 @@ exports.getCourseReadingProgress = async (req, res) => { return { ...entry, user: { - email: u?.email ?? null, - full_name: u?.personal_info?.name?.full_name ?? null, - avatar_url: avatar?.url ?? null, + email: u?.email ?? null, + full_name: u?.personal_info?.name?.full_name ?? null, + avatar_stream_token: avatar?.stream_token ?? null, }, units_total, lessons_total, diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index f07f516..dd24fbe 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -2614,13 +2614,13 @@ exports.syncInstructors = async (req, res) => { // ─── COMPLETIONS HELPERS ────────────────────────────────────────────────────── 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); return { - full_name: user.personal_info?.name?.full_name ?? null, - email: user.email ?? null, - avatar_url: avatar?.url ?? null, - deleted: !!user.deletedAt, + full_name: user.personal_info?.name?.full_name ?? null, + email: user.email ?? null, + avatar_stream_token: avatar?.stream_token ?? null, + deleted: !!user.deletedAt, }; } @@ -2629,12 +2629,12 @@ async function groupByUser(attempts) { for (const a of attempts) { const uid = String(a.user_id); 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, { user_id: a.user_id, full_name, email, - avatar_url, + avatar_stream_token, deleted, attempt_count: 0, best_score: 0, @@ -2757,7 +2757,7 @@ exports.getAssessmentSessions = async (req, res) => { const rows = await Promise.all(sessions.map(async (s) => { 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 ? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000) : null; @@ -2766,7 +2766,7 @@ exports.getAssessmentSessions = async (req, res) => { user_id: j.user_id, full_name, email, - avatar_url, + avatar_stream_token, deleted, status: j.status, started_at: j.started_at, diff --git a/controllers/admin/products.controller.js b/controllers/admin/products.controller.js index 19befee..6ce4725 100644 --- a/controllers/admin/products.controller.js +++ b/controllers/admin/products.controller.js @@ -79,21 +79,11 @@ function makeProductHandlers(purchasable_type, paramName) { } const courseProductHandlers = makeProductHandlers('course', 'courseId'); -const unitProductHandlers = makeProductHandlers('unit', 'unitId'); -const lessonProductHandlers = makeProductHandlers('lesson', 'lessonId'); exports.getCourseProduct = courseProductHandlers.get; exports.upsertCourseProduct = courseProductHandlers.upsert; 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) ────────────────────────────────────────────────── exports.getCourseCategories = async (req, res) => { diff --git a/controllers/admin/profile.controller.js b/controllers/admin/profile.controller.js index 8df70ef..630b4e0 100644 --- a/controllers/admin/profile.controller.js +++ b/controllers/admin/profile.controller.js @@ -15,10 +15,10 @@ ***********************************************************************************************************************************************************************/ 'use strict'; -const mdl_Users = require('../../models/users/users.mdl'); -const R = require('../../utils/response.util'); -const { uploadFile, deleteFile } = require('../../services/s3.service'); -const { resolveUserAvatar } = require('../../utils/resolveAvatar.util'); +const mdl_Users = require('../../models/users/users.mdl'); +const R = require('../../utils/response.util'); +const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service'); +const { resolveUserAvatar } = require('../../utils/resolveAvatar.util'); // ─── GET own profile ─────────────────────────────────────────────────────────── @@ -69,28 +69,9 @@ exports.uploadAvatar = async (req, res) => { const user = await mdl_Users.findByPk(req.user.user_id); - // Remove old avatar from S3 before replacing - 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 avatarMeta = await replaceUserAvatar(user, req.file); + const merged = { ...(user.personal_info || {}), avatar: avatarMeta }; await user.update({ personal_info: merged }); 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)); } catch (err) { + if (err.status === 400) return R.error(res, err.message, 400); console.error('[ADMIN] uploadAvatar error:', err); return R.error(res, 'Avatar upload failed.', 500); } @@ -108,16 +90,15 @@ exports.uploadAvatar = async (req, res) => { exports.deleteAvatar = async (req, res) => { try { 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 }; await user.update({ personal_info: merged }); return R.success(res, 'Avatar removed.'); } catch (err) { + if (err.status === 404) return R.error(res, err.message, 404); console.error('[ADMIN] deleteAvatar error:', err); return R.error(res, 'Could not remove avatar.', 500); } diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js index 0351e20..8e7ea20 100644 --- a/controllers/admin/tiers.controller.js +++ b/controllers/admin/tiers.controller.js @@ -29,7 +29,7 @@ const { paginate } = require('../../utils/paginate.util'); const { getFieldValues } = require('../../utils/fieldValues.util'); const logActivity = require('../../utils/logActivity.util'); const { snapshotPlanGrants } = require('../../services/tierGrants.service'); -const { revokePlanSubscriberAccess } = require('../../services/planAccess.service'); +const { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk } = require('../../services/planAccess.service'); const { excludeAttributes: plansExclude, @@ -244,16 +244,14 @@ exports.bulkArchivePlans = async (req, res) => { await mdl_TierPlans.destroy({ where: { plan_id: activeIds } }); // 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. + // one batched call across all selected plans (each still fires its own + // tier_plan_access_revoked with its own label) instead of one revoke call + // per plan, so this stays O(1) DB round trips regardless of selection size. let revoked_user_count = 0; - 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); - } + try { + ({ revoked_user_count } = await revokePlanSubscriberAccessBulk(activePlans, req.user?.user_id ?? null)); + } catch (revokeErr) { + console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr); } logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length, revoked_user_count } }); @@ -380,13 +378,10 @@ 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; - } + // active subscribers (each plan still gets its own label on the + // notification/email) before the records are gone for good — batched in + // one call across all selected plans instead of one call per plan. + const { revoked_user_count } = await revokePlanSubscriberAccessBulk(archivedPlans, req.user?.user_id ?? null); // payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted // by default) — plans can't be force-destroyed while payment rows still diff --git a/controllers/admin/user_activity.controller.js b/controllers/admin/user_activity.controller.js index d99cc83..ea444e9 100644 --- a/controllers/admin/user_activity.controller.js +++ b/controllers/admin/user_activity.controller.js @@ -123,12 +123,12 @@ async function formatRow(row) { const info = r.user?.personal_info; const avatar = await resolveAvatarUrl(info?.avatar); return { - activity_id: r.activity_id, - user_id: r.user_id, - email: r.user?.email ?? null, - full_name: info?.name?.full_name ?? null, - avatar_url: avatar?.url ?? null, - acc_type: r.user?.acc_type ?? null, + activity_id: r.activity_id, + user_id: r.user_id, + email: r.user?.email ?? null, + full_name: info?.name?.full_name ?? null, + avatar_stream_token: avatar?.stream_token ?? null, + acc_type: r.user?.acc_type ?? null, action: r.action, entity_type: r.entity_type, entity_id: r.entity_id, diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index c887f48..2890f60 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -28,6 +28,7 @@ const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); const { enrichPersonalInfo } = require('../../utils/personalInfo.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'); @@ -119,7 +120,7 @@ exports.getUser = async (req, res) => { }); 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) { console.error('[ADMIN][GET USER]', err); return R.error(res, 'Could not retrieve user.', 500); diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index 00dc5a8..ddb5be4 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -158,8 +158,6 @@ async function canAccessUnit(user_id, unit_id) { const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] }); 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 // 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), @@ -182,8 +180,6 @@ async function canAccessLesson(user_id, lesson_id) { const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] }); 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'] }); if (!unitLinks.length) return !lesson?.subscription || lesson.subscription === 'free'; for (const link of unitLinks) { @@ -1311,12 +1307,11 @@ exports.getLessonsByUnitUuid = async (req, res) => { if (!await canAccessUnit(req.user.user_id, unit.unit_id)) { 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({ status: "error", message: "You do not have access to this unit.", 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)) { 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({ status: "error", message: "You do not have access to this lesson.", 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) ─────────────────────────────────────── -// Deliberately does NOT hard-403 on locked content like getCourse/ -// getUnitByUuid/getLessonByUuid do — a locked-and-unpurchased item is exactly -// who needs to land on this page and see title/description/product, so it -// can't gate on the same canAccess*() check those content-serving routes use. -// Auth-only; content stays fully protected behind the routes above. - -const CHECKOUT_PK = { course: "course_id", unit: "unit_id", lesson: "lesson_id" }; +// ─── CHECKOUT INFO (course) ──────────────────────────────────────────────── +// Deliberately does NOT hard-403 on locked content like getCourse does — a +// locked-and-unpurchased course is exactly who needs to land on this page and +// see title/description/product, so it can't gate on the same canAccess*() +// check those content-serving routes use. Auth-only; content stays fully +// protected behind the routes above. async function buildCheckoutInfo(user_id, purchasable_type, record) { 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"], }); const hasPurchase = product && await mdl_CoursePurchase.findOne({ @@ -1528,30 +1520,4 @@ exports.getCourseCheckoutInfo = async (req, res) => { console.error("[CLIENT][COURSES][CHECKOUT INFO]", err); 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); - } }; \ No newline at end of file diff --git a/controllers/client/profile.controller.js b/controllers/client/profile.controller.js index 01f06eb..3864b52 100644 --- a/controllers/client/profile.controller.js +++ b/controllers/client/profile.controller.js @@ -19,9 +19,9 @@ const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); const mdl_Achievements = require('../../models/users/achievements.mdl'); const trustedDevice = require('../../services/trustedDevice.service'); const logActivity = require('../../utils/logActivity.util'); -const R = require('../../utils/response.util'); -const { uploadFile, deleteFile } = require('../../services/s3.service'); -const { resolveUserAvatar } = require('../../utils/resolveAvatar.util'); +const R = require('../../utils/response.util'); +const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service'); +const { resolveUserAvatar } = require('../../utils/resolveAvatar.util'); // ─── GET own profile ─────────────────────────────────────────────────────────── @@ -114,28 +114,9 @@ exports.uploadAvatar = async (req, res) => { const user = await mdl_Users.findByPk(req.user.user_id); - // Remove old avatar from S3 before replacing - 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 avatarMeta = await replaceUserAvatar(user, req.file); + const merged = { ...(user.personal_info || {}), avatar: avatarMeta }; await user.update({ personal_info: merged }); 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)); } catch (err) { + if (err.status === 400) return R.error(res, err.message, 400); console.error('[CLIENT] uploadAvatar error:', err); return R.error(res, 'Avatar upload failed.', 500); } @@ -153,16 +135,15 @@ exports.uploadAvatar = async (req, res) => { exports.deleteAvatar = async (req, res) => { try { 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 }; await user.update({ personal_info: merged }); return R.success(res, 'Avatar removed.'); } catch (err) { + if (err.status === 404) return R.error(res, err.message, 404); console.error('[CLIENT] deleteAvatar error:', err); return R.error(res, 'Could not remove avatar.', 500); } diff --git a/controllers/client/units.controller.js b/controllers/client/units.controller.js index 308adda..31eb9f9 100644 --- a/controllers/client/units.controller.js +++ b/controllers/client/units.controller.js @@ -30,12 +30,9 @@ ***********************************************************************************************************************************************************************/ "use strict"; -const { Op } = require("sequelize"); const R = require("../../utils/response.util"); const logActivity = require("../../utils/logActivity.util"); const sequelize = require("../../config/db.config"); -const mdl_Product = require("../../models/courses/products.mdl"); -const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl"); const { 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) ────────────────────────────────────────────── // 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); } - const { productById, purchasedIds } = await attachProducts(req.user.user_id, "unit", unitIds); - // is_locked mirrors canAccessUnit: a unit with its own subscription or at // least one attached course needs an access check; a fully open standalone // 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) ? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id)) : 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({ ...row, courses: coursesByUnit.get(row.unit_id) ?? [], is_locked, - product, - has_purchased, - purchase_eligible, }); } @@ -202,8 +162,6 @@ exports.getLessons = async (req, res) => { 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 // at least one attached unit needs an access check; a fully open // 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) ? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id)) : 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({ ...row, courses: coursesByLesson.get(row.lesson_id) ?? [], is_locked, - product, - has_purchased, - purchase_eligible, }); } @@ -537,5 +489,3 @@ exports.markStandaloneLessonComplete = async (req, res) => { exports.getUnitByUuid = coursesCtrl.getUnitByUuid; exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid; exports.getLessonByUuid = coursesCtrl.getLessonByUuid; -exports.getUnitCheckoutInfo = coursesCtrl.getUnitCheckoutInfo; -exports.getLessonCheckoutInfo = coursesCtrl.getLessonCheckoutInfo; diff --git a/models/courses/products.mdl.js b/models/courses/products.mdl.js index 5a5dd3a..89dc9ad 100644 --- a/models/courses/products.mdl.js +++ b/models/courses/products.mdl.js @@ -2,11 +2,11 @@ const { DataTypes } = require('sequelize'); const sequelize = require('../../config/db.config'); 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 -// Lesson (purchasable_type + purchasable_id), not just courses. See +// Polymorphic target — a product is sold against a Course (purchasable_type + +// 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 // by every consumer (access checks, admin CRUD, checkout). // @@ -30,12 +30,9 @@ const mdl_Product = sequelize.define('Product', { paranoid: true, }); -// Scoped hasOne per target type — Sequelize automatically adds the matching -// purchasable_type filter to the join, so existing -// `include: [{ model: mdl_Product, as: 'product' }]` call sites on Course -// keep working unchanged. +// Scoped hasOne — Sequelize automatically adds the matching purchasable_type +// filter to the join, so existing `include: [{ model: mdl_Product, as: +// 'product' }]` call sites on Course keep working unchanged. 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; diff --git a/package-lock.json b/package-lock.json index 6558c57..eabb9a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "rate-limit-redis": "^4.0.0", "redis": "^4.6.7", "sequelize": "^6.32.1", + "sharp": "^0.35.3", "ua-parser-js": "^2.0.10", "uuid": "^9.0.0" }, @@ -992,10 +993,8 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1012,6 +1011,554 @@ "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": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3304,6 +3851,15 @@ ], "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -6827,9 +7383,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7016,6 +7572,55 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 78cf112..044bc49 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "rate-limit-redis": "^4.0.0", "redis": "^4.6.7", "sequelize": "^6.32.1", + "sharp": "^0.35.3", "ua-parser-js": "^2.0.10", "uuid": "^9.0.0" }, diff --git a/routes/admin/products.routes.js b/routes/admin/products.routes.js index b2a26b5..4399c0a 100644 --- a/routes/admin/products.routes.js +++ b/routes/admin/products.routes.js @@ -7,16 +7,6 @@ router.get ('/courses/:courseId/product', ctrl.getCourseProduct); router.put ('/courses/:courseId/product', ctrl.upsertCourseProduct); 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 router.get ('/courses/:courseId/categories', ctrl.getCourseCategories); router.post ('/courses/:courseId/categories', ctrl.syncCourseCategories); diff --git a/routes/client/lessons.routes.js b/routes/client/lessons.routes.js index 4c93c61..a0fbfb7 100644 --- a/routes/client/lessons.routes.js +++ b/routes/client/lessons.routes.js @@ -16,7 +16,6 @@ const router = express.Router(); const ctrl = require('../../controllers/client/units.controller'); router.get('/', ctrl.getLessons); -router.get('/:uuid/checkout-info', ctrl.getLessonCheckoutInfo); router.get('/:uuid', ctrl.getLessonByUuid); router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress); router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress); diff --git a/routes/client/units.routes.js b/routes/client/units.routes.js index a47f44e..176a833 100644 --- a/routes/client/units.routes.js +++ b/routes/client/units.routes.js @@ -19,7 +19,6 @@ const ctrl = require('../../controllers/client/units.controller'); router.get('/', ctrl.getUnits); router.get('/:uuid/lessons', ctrl.getLessonsByUnitUuid); router.get('/:uuid/quiz', ctrl.getUnitQuiz); -router.get('/:uuid/checkout-info', ctrl.getUnitCheckoutInfo); router.patch('/:uuid/quiz/:quizId/draft', ctrl.saveUnitQuizDraft); router.post('/:uuid/quiz/:quizId/submit', ctrl.submitUnitQuiz); router.get('/:uuid', ctrl.getUnitByUuid); diff --git a/server.js b/server.js index 0855f2c..463b294 100644 --- a/server.js +++ b/server.js @@ -19,6 +19,12 @@ ***********************************************************************************************************************************************************************/ 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 // 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 diff --git a/services/avatar.service.js b/services/avatar.service.js new file mode 100644 index 0000000..b535970 --- /dev/null +++ b/services/avatar.service.js @@ -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 }; diff --git a/services/mediaToken.service.js b/services/mediaToken.service.js index 3ed6183..3a9a366 100644 --- a/services/mediaToken.service.js +++ b/services/mediaToken.service.js @@ -69,21 +69,31 @@ function resolveIp(req) { 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( - { - asset_id: asset.asset_id, - user_id: userId, - storage_key: asset.storage_key, - file_type: asset.file_type, - mime_type: asset.mime_type, - ip, - }, + { asset_id, user_id, storage_key, file_type, mime_type, ip }, 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 ───────────────────────────────────────────────────────────── // // Returns { token, thumbnail_url } for an S3 asset, minting + caching on first @@ -114,4 +124,5 @@ module.exports = { SUPPORTED_TYPES, resolveIp, issueForAsset, + signMediaToken, }; diff --git a/services/planAccess.service.js b/services/planAccess.service.js index 99d937c..c890b5f 100644 --- a/services/planAccess.service.js +++ b/services/planAccess.service.js @@ -20,14 +20,22 @@ const { NOTIFICATION_REGISTRY } = require('../data/notifications.data'); const { sendEmail } = require('./email.service'); const { fmtDate } = require('../utils/datetime.util'); -// Revokes every active user_tiers row tied to `plan`, replicating revokeTier's -// per-user "auto-downgrade to Free if no other active tier remains" rule -// (controllers/admin/tiers.controller.js) — a user can hold more than one -// concurrently-active plan, so this can't be a blanket status update. -async function revokePlanSubscriberAccess(plan, revokedByUserId) { +// Revokes every active user_tiers row tied to any of `plans`, replicating +// revokeTier's per-user "auto-downgrade to Free if no other active tier +// remains" rule (controllers/admin/tiers.controller.js) — a user can hold +// more than one concurrently-active plan, so this can't be a blanket status +// 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({ - where: { plan_id: plan.plan_id, status: 'active' }, - attributes: ['tier_id', 'user_id'], + where: { plan_id: planIds, status: 'active' }, + attributes: ['tier_id', 'user_id', 'plan_id'], }); if (!activeRows.length) return { revoked_user_count: 0 }; @@ -40,10 +48,19 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) { { where: { tier_id: tierIds } }, ); - for (const user_id of userIds) { - const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } }); - if (remainingActive === 0) { - await mdl_UserTiers.create({ + // One grouped query replaces a per-user COUNT: finds everyone who still + // holds another active tier after the revoke above. + const stillActiveRows = await mdl_UserTiers.findAll({ + 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, tier: 'free', status: 'active', @@ -51,16 +68,23 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) { expires_at: null, granted_by: revokedByUserId, 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 { - const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.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 }, - ); + const notifications = revokedPairs.map((r) => { + const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.build({ + label: labelByPlanId.get(String(r.plan_id)), + 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) { console.error('[PLAN ACCESS REVOKE][NOTIFY]', notifyErr); } @@ -70,13 +94,16 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) { where: { user_id: userIds }, attributes: ['user_id', 'email', 'personal_info'], }); + const usersById = new Map(users.map((u) => [String(u.user_id), u])); 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'; sendEmail({ to: u.email, 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 (emailBatchErr) { @@ -86,4 +113,8 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) { return { revoked_user_count: userIds.length }; } -module.exports = { revokePlanSubscriberAccess }; +async function revokePlanSubscriberAccess(plan, revokedByUserId) { + return revokePlanSubscriberAccessBulk([plan], revokedByUserId); +} + +module.exports = { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk }; diff --git a/tests/services/planAccess.service.test.js b/tests/services/planAccess.service.test.js index cf15cfe..f51a171 100644 --- a/tests/services/planAccess.service.test.js +++ b/tests/services/planAccess.service.test.js @@ -10,8 +10,7 @@ jest.mock('../../models/tiers/user_tiers.mdl', () => ({ findAll: jest.fn(), update: jest.fn(), - count: jest.fn(), - create: jest.fn(), + bulkCreate: jest.fn(), })); jest.mock('../../models/users/users.mdl', () => ({ findAll: 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 { sendEmail } = require('../../services/email.service'); -const { revokePlanSubscriberAccess } = require('../../services/planAccess.service'); +const { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk } = require('../../services/planAccess.service'); function makePlan(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 () => { - mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 1, user_id: 5 }]); - mdl_UserTiers.count.mockResolvedValue(0); // no other active tier remains + mdl_UserTiers.findAll + .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' } } }]); const result = await revokePlanSubscriberAccess(makePlan(), 99); @@ -53,28 +53,30 @@ describe('revokePlanSubscriberAccess()', () => { expect.objectContaining({ status: 'revoked', revoked_by: 99 }), { where: { tier_id: [1] } } ); - expect(mdl_UserTiers.create).toHaveBeenCalledWith( - expect.objectContaining({ user_id: '5', tier: 'free', status: 'active' }) - ); + expect(mdl_UserTiers.bulkCreate).toHaveBeenCalledWith([ + expect.objectContaining({ user_id: '5', tier: 'free', status: 'active' }), + ]); expect(result).toEqual({ revoked_user_count: 1 }); }); 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.count.mockResolvedValue(1); // still holds a different active plan + mdl_UserTiers.findAll + .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: {} }]); 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 () => { - mdl_UserTiers.findAll.mockResolvedValue([ - { tier_id: 1, user_id: 5 }, - { tier_id: 2, user_id: 6 }, - ]); - mdl_UserTiers.count.mockResolvedValue(1); + mdl_UserTiers.findAll + .mockResolvedValueOnce([ + { tier_id: 1, user_id: 5, plan_id: 10 }, + { tier_id: 2, user_id: 6, plan_id: 10 }, + ]) + .mockResolvedValueOnce([{ user_id: 5 }, { user_id: 6 }]); mdl_Users.findAll.mockResolvedValue([ { user_id: 5, email: 'a@b.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 }); }); }); + +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 }); + }); +}); diff --git a/utils/resolveAvatar.util.js b/utils/resolveAvatar.util.js index b7ba111..c3a9402 100644 --- a/utils/resolveAvatar.util.js +++ b/utils/resolveAvatar.util.js @@ -1,33 +1,40 @@ // 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: -// - S3-stored avatars (avatar.uuid present) were historically saved with a -// raw, unsigned bucket URL (see s3.service.js buildPublicUrl). The current -// Garage ingress (Cloudflare lane, see chibistar/Caddyfile) forwards reads -// straight to Garage with no re-signing, and Garage rejects anonymous -// requests outright — so that stored URL 403s in the browser. Even a -// presigned URL would go stale if persisted (getPublicUrl() expires in 4h), -// 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. +// personal_info.avatar is never handed to the browser as a raw S3 URL — +// same protection as every other media type in this app (see +// controllers/client/media.controller.js): the browser only ever gets a +// short-lived opaque stream_token, proxied through +// GET /client/media/stream/:token, which mints the real presigned URL +// server-side and pipes the bytes back. The real bucket/key/signature never +// reach the DOM. +// +// - 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 // 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) { if (!avatar) return avatar ?? null; - if (!avatar.uuid) return avatar; // external URL (e.g. Google) — nothing to sign - try { - const url = await getPublicUrl(avatar.uuid); - return { ...avatar, url }; - } catch (err) { - console.error('[AVATAR] Failed to resolve presigned URL for', avatar.uuid, err); - return avatar; // fall back to the stored value rather than failing the whole response + if (!avatar.uuid) { + // External URL (e.g. Google) — nothing to sign, just normalize the field + // name to match the { stream_token?, file_url? } shape resolveAssetSrc() + // already expects for every other media payload. + return { ...avatar, url: undefined, file_url: avatar.url }; } + + 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 diff --git a/utils/suspendGuard.util.js b/utils/suspendGuard.util.js new file mode 100644 index 0000000..5ad5df7 --- /dev/null +++ b/utils/suspendGuard.util.js @@ -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 };