diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js index 6f63da3..72692b1 100644 --- a/controllers/admin/assets.controller.js +++ b/controllers/admin/assets.controller.js @@ -8,6 +8,8 @@ const s3 = require("../../services/s3.service"); const mediaToken = require("../../services/mediaToken.service"); const uploadProgress = require("../../services/uploadProgress.service"); const { extractVideoMeta } = require("../../services/ffprobe.service"); +const ffmpegSvc = require("../../services/ffmpeg.service"); +const assetTranscode = require("../../services/assetTranscode.service"); const documentConversion = require("../../services/documentConversion.service"); const R = require('../../utils/response.util'); const { paginate } = require("../../utils/paginate.util"); @@ -408,6 +410,11 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or // ── DB insert ────────────────────────────────────────────────────────────── + // .mov/.mkv videos load slowly in-browser (moov/Cues index at the end of + // the file) — flag them for the background remux job (see + // assetTranscode.service.js) fired below, right after commit. + const needsTranscode = storage_provider === "s3" && file_type === "video" && ffmpegSvc.needsRemux(extension); + const t = await sequelize.transaction(); try { const asset = await Asset.create({ @@ -434,10 +441,18 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or storage_key, is_public, createdBy, + transcode_status: needsTranscode ? "pending" : "none", }, { transaction: t }); await t.commit(); logActivity(user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } }); + + if (needsTranscode) { + assetTranscode.transcodeAsset(asset).catch((err) => { + console.error("[ASSET][TRANSCODE] Background remux failed to start:", err.message); + }); + } + return asset; } catch (dbErr) { diff --git a/controllers/admin/course_reading_progress.controller.js b/controllers/admin/course_reading_progress.controller.js index 0c9000e..aa9d717 100644 --- a/controllers/admin/course_reading_progress.controller.js +++ b/controllers/admin/course_reading_progress.controller.js @@ -20,6 +20,7 @@ const CourseReadingProgress = require('../../models/courses/course_reading_progr const { Course, Unit, Lesson, CourseUnit } = require('../../models/courses/courses.associations'); const mdl_Users = require('../../models/users/users.mdl'); const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util'); +const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util'); const notDeleted = { deletedAt: null }; @@ -91,21 +92,22 @@ exports.getCourseReadingProgress = async (req, res) => { if (row.type === 'lesson' && row.status === 'completed') entry.lessons_completed++; } - const result = Object.values(summaryMap).map((entry) => { + const result = await Promise.all(Object.values(summaryMap).map(async (entry) => { const u = userMap[entry.user_id]; + const avatar = await resolveAvatarUrl(u?.personal_info?.avatar); return { ...entry, user: { email: u?.email ?? null, full_name: u?.personal_info?.name?.full_name ?? null, - avatar_url: u?.personal_info?.avatar?.url ?? null, + avatar_url: avatar?.url ?? null, }, units_total, lessons_total, // Fall back to in_progress if the course row hasn't been written yet course_status: entry.course_status ?? 'in_progress', }; - }); + })); // Sort: completed last, most recent first within each group result.sort((a, b) => { diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 006d9f9..c469751 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -5,6 +5,7 @@ const sequelize = require("../../config/db.config"); const R = require("../../utils/response.util"); const { paginate } = require("../../utils/paginate.util"); const { recomputeDurations, recomputeUnitDuration, recomputeCourseDuration, formatDuration } = require("../../utils/duration.util"); +const { resolveAvatarUrl } = require("../../utils/resolveAvatar.util"); const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util"); const { resolvePrerequisiteTitles } = require("../../utils/courses/resolvePrerequisiteTitles.util"); const { syncJunction } = require("../../utils/courses/junction.util"); @@ -2653,22 +2654,23 @@ exports.syncInstructors = async (req, res) => { // ─── COMPLETIONS HELPERS ────────────────────────────────────────────────────── -function extractUserInfo(user) { +async function extractUserInfo(user) { if (!user) return { full_name: null, email: null, avatar_url: 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: user.personal_info?.avatar?.url ?? null, + avatar_url: avatar?.url ?? null, deleted: !!user.deletedAt, }; } -function groupByUser(attempts) { +async function groupByUser(attempts) { const map = new Map(); for (const a of attempts) { const uid = String(a.user_id); if (!map.has(uid)) { - const { full_name, email, avatar_url, deleted } = extractUserInfo(a.user); + const { full_name, email, avatar_url, deleted } = await extractUserInfo(a.user); map.set(uid, { user_id: a.user_id, full_name, @@ -2749,7 +2751,7 @@ exports.getQuizCompletions = async (req, res) => { const plain = attempts.map((a) => a.toJSON()); return R.success(res, "Quiz completions retrieved.", { summary: buildSummary(plain), - completions: groupByUser(plain), + completions: await groupByUser(plain), }); } catch (err) { console.error("[ADMIN][QUIZ][COMPLETIONS]", err); @@ -2773,7 +2775,7 @@ exports.getAssessmentCompletions = async (req, res) => { const plain = attempts.map((a) => a.toJSON()); return R.success(res, "Assessment completions retrieved.", { summary: buildSummary(plain), - completions: groupByUser(plain), + completions: await groupByUser(plain), }); } catch (err) { console.error("[ADMIN][ASSESSMENT][COMPLETIONS]", err); @@ -2794,9 +2796,9 @@ exports.getAssessmentSessions = async (req, res) => { order: [["createdAt", "DESC"]], }); - const rows = sessions.map((s) => { + const rows = await Promise.all(sessions.map(async (s) => { const j = s.toJSON(); - const { full_name, email, avatar_url, deleted } = extractUserInfo(j.user); + const { full_name, email, avatar_url, 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; @@ -2813,7 +2815,7 @@ exports.getAssessmentSessions = async (req, res) => { time_spent_seconds, attempt_id: j.attempt_id, }; - }); + })); const summary = { total_sessions: rows.length, diff --git a/controllers/admin/lessons.controller.js b/controllers/admin/lessons.controller.js index 7d48a41..514c69a 100644 --- a/controllers/admin/lessons.controller.js +++ b/controllers/admin/lessons.controller.js @@ -37,6 +37,7 @@ const { } = require("../../models/courses/courses.associations"); const mdl_Users = require("../../models/users/users.mdl"); +const { mdl_PlanLessons, mdl_TierPlans } = require("../../models/tiers/tier.associations"); const notDeleted = { deletedAt: null }; const onlyDeleted = { deletedAt: { [Op.not]: null } }; @@ -155,6 +156,46 @@ exports.getLessonsFlat = async (req, res) => { } }; +// Bundle picker (Tier Plan wizard) — mirrors getCoursesBySubscription in +// controllers/admin/courses.controller.js. +exports.getLessonsBySubscription = async (req, res) => { + try { + const { slug } = req.query; + if (!slug) return R.error(res, 'slug query param is required.', 400); + + const rows = await Lesson.findAll({ + where: { ...notDeleted, subscription: slug }, + attributes: ['lesson_id', 'title', 'description', 'subscription'], + include: [{ + model: mdl_PlanLessons, + as: 'planLesson', + required: false, + attributes: ['plan_id'], + include: [{ + model: mdl_TierPlans, + as: 'plan', + attributes: ['plan_id', 'label'], + }], + }], + order: [['title', 'ASC']], + }); + + // Flatten so the frontend can just check `assigned_plan` — a lesson + // belongs to at most one plan (UNIQUE constraint on plan_lessons.lesson_id). + const data = rows.map((l) => { + const plain = l.toJSON(); + const assigned_plan = plain.planLesson?.plan ?? null; + delete plain.planLesson; + return { ...plain, assigned_plan }; + }); + + return R.success(res, 'Lessons retrieved.', data); + } catch (err) { + console.error('[LESSON LIB][BY SUBSCRIPTION]', err); + return R.error(res, 'Could not retrieve lessons.', 500); + } +}; + exports.getLesson = async (req, res) => { try { const { lessonId } = req.params; @@ -179,11 +220,12 @@ exports.getLesson = async (req, res) => { exports.createLesson = async (req, res) => { const t = await sequelize.transaction(); try { - const { title, description, unit_id, order, objectives = [], createdBy } = req.body; + const { title, description, subscription, unit_id, order, objectives = [], createdBy } = req.body; if (!title) return R.error(res, "Title is required.", 400); const lesson = await Lesson.create({ title, + subscription: subscription || null, description: description ?? null, duration_seconds: 0, createdBy: createdBy ?? req.user?.user_id ?? null, @@ -231,10 +273,11 @@ exports.updateLesson = async (req, res) => { const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } }); if (!lesson) return R.error(res, "Lesson not found.", 404); - const { title, description, objectives, updatedBy } = req.body; + const { title, description, subscription, objectives, updatedBy } = req.body; - if (title !== undefined) lesson.title = title; - if (description !== undefined) lesson.description = description; + if (title !== undefined) lesson.title = title; + if (description !== undefined) lesson.description = description; + if (subscription !== undefined) lesson.subscription = subscription || null; lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null; await lesson.save({ transaction: t }); diff --git a/controllers/admin/products.controller.js b/controllers/admin/products.controller.js index 41d6698..19befee 100644 --- a/controllers/admin/products.controller.js +++ b/controllers/admin/products.controller.js @@ -5,54 +5,94 @@ const { Course, CourseProductCategory: mdl_CourseProductCategory } = require('.. const R = require('../../utils/response.util'); const logActivity = require('../../utils/logActivity.util'); -// ─── PRODUCT (per course) ───────────────────────────────────────────────────── +// ─── PRODUCT (generic, keyed by purchasable_type + purchasable_id) ─────────── +// Course/Unit/Lesson each get their own thin route + exported handler below, +// all delegating to these so the CRUD logic isn't tripled across the three +// content types — see routes/admin/products.routes.js. -exports.getCourseProduct = async (req, res) => { - try { - const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId }, paranoid: false }); - return R.success(res, 'Product retrieved.', product ?? null); - } catch (err) { - console.error('[ADMIN][PRODUCTS][GET]', err); - return R.error(res, 'Could not retrieve product.', 500); +async function getProductFor(purchasable_type, purchasable_id) { + return mdl_Product.findOne({ where: { purchasable_type, purchasable_id }, paranoid: false }); +} + +async function upsertProductFor(purchasable_type, purchasable_id, body, adminUserId) { + const { name, description, price, currency, access_days, is_active } = body; + if (!name || price == null) { + const err = new Error('name and price are required.'); + err.status = 400; + throw err; } -}; -exports.upsertCourseProduct = async (req, res) => { - try { - const { courseId } = req.params; - const { name, description, price, currency, access_days, is_active } = req.body; - if (!name || price == null) return R.error(res, 'name and price are required.', 400); + const existing = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id }, paranoid: false }); - const existing = await mdl_Product.findOne({ where: { course_id: courseId }, paranoid: false }); - - if (existing) { - if (existing.deletedAt) await existing.restore(); - await existing.update({ name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true }); - logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', details: { course_id: courseId, name } }); - return R.success(res, 'Product updated.', existing); - } - - const product = await mdl_Product.create({ course_id: courseId, name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true }); - logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', entityId: product.product_id, details: { course_id: courseId, name } }); - return R.success(res, 'Product created.', product, 201); - } catch (err) { - console.error('[ADMIN][PRODUCTS][UPSERT]', err); - return R.error(res, 'Could not save product.', 500); + if (existing) { + if (existing.deletedAt) await existing.restore(); + await existing.update({ name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true }); + logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: existing.id, details: { purchasable_type, purchasable_id, name } }); + return { product: existing, created: false }; } -}; -exports.removeCourseProduct = async (req, res) => { - try { - const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId } }); - if (!product) return R.error(res, 'Product not found.', 404); - await product.destroy(); - logActivity(req.user?.user_id, 'remove_course_product', { entityType: 'product', details: { course_id: req.params.courseId } }); - return R.success(res, 'Product removed.'); - } catch (err) { - console.error('[ADMIN][PRODUCTS][REMOVE]', err); - return R.error(res, 'Could not remove product.', 500); - } -}; + const product = await mdl_Product.create({ purchasable_type, purchasable_id, name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true }); + logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: product.id, details: { purchasable_type, purchasable_id, name } }); + return { product, created: true }; +} + +async function removeProductFor(purchasable_type, purchasable_id, adminUserId) { + const product = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id } }); + if (!product) return false; + await product.destroy(); + logActivity(adminUserId, `remove_${purchasable_type}_product`, { entityType: 'product', details: { purchasable_type, purchasable_id } }); + return true; +} + +function makeProductHandlers(purchasable_type, paramName) { + return { + get: async (req, res) => { + try { + const product = await getProductFor(purchasable_type, req.params[paramName]); + return R.success(res, 'Product retrieved.', product ?? null); + } catch (err) { + console.error(`[ADMIN][PRODUCTS][GET][${purchasable_type}]`, err); + return R.error(res, 'Could not retrieve product.', 500); + } + }, + upsert: async (req, res) => { + try { + const { product, created } = await upsertProductFor(purchasable_type, req.params[paramName], req.body, req.user?.user_id); + return R.success(res, created ? 'Product created.' : 'Product updated.', product, created ? 201 : 200); + } catch (err) { + if (err.status === 400) return R.error(res, err.message, 400); + console.error(`[ADMIN][PRODUCTS][UPSERT][${purchasable_type}]`, err); + return R.error(res, 'Could not save product.', 500); + } + }, + remove: async (req, res) => { + try { + const removed = await removeProductFor(purchasable_type, req.params[paramName], req.user?.user_id); + if (!removed) return R.error(res, 'Product not found.', 404); + return R.success(res, 'Product removed.'); + } catch (err) { + console.error(`[ADMIN][PRODUCTS][REMOVE][${purchasable_type}]`, err); + return R.error(res, 'Could not remove product.', 500); + } + }, + }; +} + +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) ────────────────────────────────────────────────── diff --git a/controllers/admin/profile.controller.js b/controllers/admin/profile.controller.js index 855a022..8df70ef 100644 --- a/controllers/admin/profile.controller.js +++ b/controllers/admin/profile.controller.js @@ -18,6 +18,7 @@ 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'); // ─── GET own profile ─────────────────────────────────────────────────────────── @@ -26,7 +27,7 @@ exports.getProfile = async (req, res) => { const user = await mdl_Users.findByPk(req.user.user_id, { attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, }); - return R.success(res, 'Profile retrieved.', user); + return R.success(res, 'Profile retrieved.', await resolveUserAvatar(user)); } catch (err) { return R.error(res, 'Could not retrieve profile.', 500); } @@ -53,7 +54,7 @@ exports.updateProfile = async (req, res) => { const updated = await mdl_Users.findByPk(req.user.user_id, { attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, }); - return R.success(res, 'Profile updated.', updated); + return R.success(res, 'Profile updated.', await resolveUserAvatar(updated)); } catch (err) { console.error('[ADMIN] updateProfile error:', err); return R.error(res, 'Profile update failed.', 500); @@ -95,7 +96,7 @@ exports.uploadAvatar = async (req, res) => { const updated = await mdl_Users.findByPk(req.user.user_id, { attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, }); - return R.success(res, 'Avatar updated.', updated); + return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated)); } catch (err) { console.error('[ADMIN] uploadAvatar error:', err); return R.error(res, 'Avatar upload failed.', 500); diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js index 087449f..a28a060 100644 --- a/controllers/admin/tiers.controller.js +++ b/controllers/admin/tiers.controller.js @@ -16,7 +16,11 @@ const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_Users = require('../../models/users/users.mdl'); const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl'); +const mdl_PlanUnits = require('../../models/tiers/plan_units.mdl'); +const mdl_PlanLessons = require('../../models/tiers/plan_lessons.mdl'); const { Course } = require('../../models/courses/courses.mdl'); +const Unit = require('../../models/courses/units.mdl'); +const Lesson = require('../../models/courses/lessons.mdl'); require('../../models/tiers/tier.associations'); @@ -546,3 +550,107 @@ exports.syncPlanCourses = async (req, res) => { return R.error(res, 'Could not update plan courses.', 500); } }; + +// ─── PLAN UNITS ─────────────────────────────────────────────────────────────── +// Mirrors PLAN COURSES above — bundling/display only, not an access-control +// mechanism (see canAccessUnit in controllers/client/courses.controller.js). + +exports.getPlanUnits = async (req, res) => { + try { + const entries = await mdl_PlanUnits.findAll({ + where: { plan_id: req.params.id }, + include: [{ + model: Unit, + as: 'unit', + attributes: ['unit_id', 'title', 'subscription'], + }], + order: [['createdAt', 'ASC']], + }); + return R.success(res, 'Plan units retrieved.', entries.map(e => e.unit)); + } catch (err) { + console.error('[ADMIN][GET PLAN UNITS]', err); + return R.error(res, 'Could not retrieve plan units.', 500); + } +}; + +exports.syncPlanUnits = async (req, res) => { + try { + const { id } = req.params; + const { unit_ids = [] } = req.body; + + if (!Array.isArray(unit_ids)) + return R.error(res, 'unit_ids must be an array.', 400); + + const plan = await mdl_TierPlans.findByPk(id); + if (!plan) return R.error(res, 'Plan not found.', 404); + + await mdl_PlanUnits.destroy({ where: { plan_id: id } }); + + if (unit_ids.length) { + // unit_id has a UNIQUE constraint — clear any orphaned/other-plan assignments + // for these units before inserting, so the insert isn't silently skipped + await mdl_PlanUnits.destroy({ where: { unit_id: unit_ids } }); + await mdl_PlanUnits.bulkCreate( + unit_ids.map(unit_id => ({ plan_id: id, unit_id })), + ); + } + + logActivity(req.user?.user_id, 'sync_plan_units', { entityType: 'tier_plan', details: { plan_id: id, unit_ids, count: unit_ids.length } }); + return R.success(res, 'Plan units updated.'); + } catch (err) { + console.error('[ADMIN][SYNC PLAN UNITS]', err); + return R.error(res, 'Could not update plan units.', 500); + } +}; + +// ─── PLAN LESSONS ───────────────────────────────────────────────────────────── +// Mirrors PLAN COURSES above — bundling/display only, not an access-control +// mechanism (see canAccessLesson in controllers/client/courses.controller.js). + +exports.getPlanLessons = async (req, res) => { + try { + const entries = await mdl_PlanLessons.findAll({ + where: { plan_id: req.params.id }, + include: [{ + model: Lesson, + as: 'lesson', + attributes: ['lesson_id', 'title', 'subscription'], + }], + order: [['createdAt', 'ASC']], + }); + return R.success(res, 'Plan lessons retrieved.', entries.map(e => e.lesson)); + } catch (err) { + console.error('[ADMIN][GET PLAN LESSONS]', err); + return R.error(res, 'Could not retrieve plan lessons.', 500); + } +}; + +exports.syncPlanLessons = async (req, res) => { + try { + const { id } = req.params; + const { lesson_ids = [] } = req.body; + + if (!Array.isArray(lesson_ids)) + return R.error(res, 'lesson_ids must be an array.', 400); + + const plan = await mdl_TierPlans.findByPk(id); + if (!plan) return R.error(res, 'Plan not found.', 404); + + await mdl_PlanLessons.destroy({ where: { plan_id: id } }); + + if (lesson_ids.length) { + // lesson_id has a UNIQUE constraint — clear any orphaned/other-plan assignments + // for these lessons before inserting, so the insert isn't silently skipped + await mdl_PlanLessons.destroy({ where: { lesson_id: lesson_ids } }); + await mdl_PlanLessons.bulkCreate( + lesson_ids.map(lesson_id => ({ plan_id: id, lesson_id })), + ); + } + + logActivity(req.user?.user_id, 'sync_plan_lessons', { entityType: 'tier_plan', details: { plan_id: id, lesson_ids, count: lesson_ids.length } }); + return R.success(res, 'Plan lessons updated.'); + } catch (err) { + console.error('[ADMIN][SYNC PLAN LESSONS]', err); + return R.error(res, 'Could not update plan lessons.', 500); + } +}; diff --git a/controllers/admin/units.controller.js b/controllers/admin/units.controller.js index 7bcd096..ac70d10 100644 --- a/controllers/admin/units.controller.js +++ b/controllers/admin/units.controller.js @@ -41,6 +41,7 @@ const CompletionRequirement = require("../../models/courses/completion_requireme const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry"); const mdl_Users = require("../../models/users/users.mdl"); +const { mdl_PlanUnits, mdl_TierPlans } = require("../../models/tiers/tier.associations"); const notDeleted = { deletedAt: null }; const onlyDeleted = { deletedAt: { [Op.not]: null } }; @@ -160,6 +161,46 @@ exports.getUnitsFlat = async (req, res) => { } }; +// Bundle picker (Tier Plan wizard) — mirrors getCoursesBySubscription in +// controllers/admin/courses.controller.js. +exports.getUnitsBySubscription = async (req, res) => { + try { + const { slug } = req.query; + if (!slug) return R.error(res, 'slug query param is required.', 400); + + const rows = await Unit.findAll({ + where: { ...notDeleted, subscription: slug }, + attributes: ['unit_id', 'title', 'description', 'subscription'], + include: [{ + model: mdl_PlanUnits, + as: 'planUnit', + required: false, + attributes: ['plan_id'], + include: [{ + model: mdl_TierPlans, + as: 'plan', + attributes: ['plan_id', 'label'], + }], + }], + order: [['title', 'ASC']], + }); + + // Flatten so the frontend can just check `assigned_plan` — a unit belongs + // to at most one plan (UNIQUE constraint on plan_units.unit_id). + const data = rows.map((u) => { + const plain = u.toJSON(); + const assigned_plan = plain.planUnit?.plan ?? null; + delete plain.planUnit; + return { ...plain, assigned_plan }; + }); + + return R.success(res, 'Units retrieved.', data); + } catch (err) { + console.error('[UNIT LIB][BY SUBSCRIPTION]', err); + return R.error(res, 'Could not retrieve units.', 500); + } +}; + exports.getUnit = async (req, res) => { try { const { unitId } = req.params; diff --git a/controllers/admin/user_activity.controller.js b/controllers/admin/user_activity.controller.js index 9a79e64..d99cc83 100644 --- a/controllers/admin/user_activity.controller.js +++ b/controllers/admin/user_activity.controller.js @@ -13,6 +13,7 @@ const { Op } = require('sequelize'); const mdl_UserActivity = require('../../models/users/user_activity.mdl'); const mdl_Users = require('../../models/users/users.mdl'); const R = require('../../utils/response.util'); +const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util'); const USER_ATTRS = [ 'user_id', 'email', 'acc_type', @@ -66,7 +67,7 @@ exports.getActivity = async (req, res) => { offset, }); - const data = rows.map(formatRow); + const data = await Promise.all(rows.map(formatRow)); return R.success(res, 'Activity feed retrieved.', { total: count, @@ -117,15 +118,16 @@ exports.getUserActivity = async (req, res) => { // ─── Helpers ────────────────────────────────────────────────────────────────── -function formatRow(row) { +async function formatRow(row) { const r = row.toJSON(); 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: info?.avatar?.url ?? null, + avatar_url: avatar?.url ?? null, acc_type: r.user?.acc_type ?? null, action: r.action, entity_type: r.entity_type, diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index 71775f6..cab7d47 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -51,16 +51,17 @@ const { sendEmail } = require('../services/email.service'); const buildSessionInfo = require('../utils/session_info.util'); const logActivity = require('../utils/logActivity.util'); const trustedDevice = require('../services/trustedDevice.service'); +const { resolveUserAvatar } = require('../utils/resolveAvatar.util'); const R = require('../utils/response.util'); const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy']; -const safeUser = (user, extraExclude = []) => { +const safeUser = async (user, extraExclude = []) => { const u = user.toJSON ? user.toJSON() : { ...user }; [...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]); - return u; + return resolveUserAvatar(u); }; const setRefreshCookie = (res, refreshToken) => { @@ -321,7 +322,7 @@ exports.verifyOTP = async (req, res) => { return R.success(res, wasVerified ? 'Login successful.' : 'Email verified successfully. You are now logged in.', { accessToken, session_id: session.session_id, - user: safeUser(user), + user: await safeUser(user), }); } catch (err) { await transaction.rollback(); @@ -403,7 +404,7 @@ exports.login = async (req, res) => { otpRequired: false, accessToken, session_id: session.session_id, - user: safeUser(user), + user: await safeUser(user), }); } @@ -637,7 +638,7 @@ exports.refreshToken = async (req, res) => { // Check if refresh token is expired if (!shouldRotateRefreshToken(decoded)) { const { accessToken } = generateTokens(user); - return R.success(res, 'Token refreshed.', { accessToken, session_id: session.session_id, user: safeUser(user) }); + return R.success(res, 'Token refreshed.', { accessToken, session_id: session.session_id, user: await safeUser(user) }); } // ─── Rotate refresh token ─────────────────────────────────────────────────── @@ -646,7 +647,7 @@ exports.refreshToken = async (req, res) => { setRefreshCookie(res, tokens.refreshToken); - return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: safeUser(user) }); + return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: await safeUser(user) }); } catch (err) { console.error('[AUTH] refresh token error:', err); return R.error(res, 'Invalid or expired refresh token.', 401); diff --git a/controllers/client/course_purchases.controller.js b/controllers/client/course_purchases.controller.js index e9e6352..964885e 100644 --- a/controllers/client/course_purchases.controller.js +++ b/controllers/client/course_purchases.controller.js @@ -3,8 +3,12 @@ const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl'); const mdl_Product = require('../../models/courses/products.mdl'); const paymentSvc = require('../../services/payment.service'); const R = require('../../utils/response.util'); +const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util'); // ─── CREATE ORDER ───────────────────────────────────────────────────────────── +// Despite the "course" naming (historical — this predates Units/Lessons being +// individually purchasable), this endpoint and table are generic: a Product's +// purchasable_type/purchasable_id drives everything below. exports.createCourseOrder = async (req, res) => { try { @@ -19,15 +23,19 @@ exports.createCourseOrder = async (req, res) => { }); if (existing) { const stillActive = !existing.expires_at || new Date(existing.expires_at) > new Date(); - if (stillActive) return R.error(res, 'You already have active access to this course.', 409); + if (stillActive) return R.error(res, `You already have active access to this ${product.purchasable_type}.`, 409); } + const target = await resolvePurchasable(product.purchasable_type, product.purchasable_id); + if (!target) return R.error(res, 'Purchasable content not found.', 404); + const path = checkoutPath(product.purchasable_type, target); + const ppOrder = await paymentSvc.createOrder('paypal', { amount: Number(product.price).toFixed(2), currency: product.currency, referenceId: `user_${req.user.user_id}_product_${product_id}`, - returnUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout`, - cancelUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout?cancelled=true`, + returnUrl: `${process.env.FRONTEND_URL}${path}`, + cancelUrl: `${process.env.FRONTEND_URL}${path}?cancelled=true`, }); const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null; @@ -100,10 +108,14 @@ exports.captureCourseOrder = async (req, res) => { }, }); - return R.success(res, 'Payment successful. Course access granted.', { - purchase_id: purchase.id, - expires_at: purchase.expires_at, - course_id: purchase.product.course_id, + const target = await resolvePurchasable(purchase.product.purchasable_type, purchase.product.purchasable_id); + + return R.success(res, 'Payment successful. Access granted.', { + purchase_id: purchase.id, + expires_at: purchase.expires_at, + purchasable_type: purchase.product.purchasable_type, + purchasable_id: purchase.product.purchasable_id, + target_uuid: target?.uuid ?? null, }); } catch (err) { console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err); @@ -144,7 +156,7 @@ exports.getMyPurchases = async (req, res) => { try { const purchases = await mdl_CoursePurchase.findAll({ where: { user_id: req.user.user_id }, - include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'course_id', 'access_days'] }], + include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'purchasable_type', 'purchasable_id', 'access_days'] }], attributes: { exclude: ['provider_payload'] }, order: [['createdAt', 'DESC']], }); diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index f26b1f9..c48274f 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -127,6 +127,23 @@ async function buildUserContext(user_id) { return { tier, tierRankMap, rulesets, group_ids, activeTiers }; } +// Individual-purchase check shared by all three content types — a Product is +// keyed by (purchasable_type, purchasable_id), see utils/purchasable.util.js. +async function hasActivePurchase(user_id, purchasable_type, purchasable_id) { + const product = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id } }); + if (!product) return false; + + const hasPurchase = await mdl_CoursePurchase.findOne({ + where: { + user_id, + product_id: product.id, + status: 'completed', + [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], + }, + }); + return !!hasPurchase; +} + // ─── Shared tier + purchase access check ───────────────────────────────────── // Returns true → user may access the course. // Returns false → user's tier is too low AND no valid individual purchase. @@ -158,25 +175,16 @@ async function canAccessCourse(user_id, course_id) { } // Individual purchase as fallback - const product = await mdl_Product.findOne({ where: { course_id } }); - if (!product) return false; - - const hasPurchase = await mdl_CoursePurchase.findOne({ - where: { - user_id, - product_id: product.id, - status: 'completed', - [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], - }, - }); - return !!hasPurchase; + return hasActivePurchase(user_id, 'course', course_id); } // ─── Standalone access checks (junction revamp) ────────────────────────────── // A Unit attached to no course is open to every authenticated user; a Unit // attached to one or more courses is open when the user can access ANY of them. -// Lessons resolve through their parent units the same way. This keeps paid -// content locked while letting genuinely standalone content run independently. +// Lessons resolve the same way through their parent units. Both Unit and +// Lesson also carry their own optional subscription/individual-purchase gate, +// full parity with Course — this keeps paid content locked while letting +// genuinely standalone content run independently. async function canAccessUnit(user_id, unit_id) { // A unit's own subscription (standalone tier-gating) is an additional, @@ -195,6 +203,8 @@ async function canAccessUnit(user_id, unit_id) { if (allowed) 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), @@ -212,8 +222,22 @@ async function canAccessUnit(user_id, unit_id) { } async function canAccessLesson(user_id, lesson_id) { + // Mirrors canAccessUnit's shape: own subscription, then own purchase, then + // fall through to attached units (OR'd — a lesson can sit in more than one). + const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] }); + if (lesson?.subscription) { + const userCtx = await buildUserContext(user_id); + const { allowed } = evaluateCourseAccess( + { tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids }, + { subscription: lesson.subscription }, userCtx.tierRankMap + ); + if (allowed) 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 true; + if (!unitLinks.length) return !lesson?.subscription; for (const link of unitLinks) { if (await canAccessUnit(user_id, link.unit_id)) return true; } @@ -280,17 +304,19 @@ exports.getCourses = async (req, res) => { const userCtx = await buildUserContext(req.user.user_id); const userTier = userCtx.tier; - // Fetch all completed purchases for this user (for has_purchased check) + // Fetch all completed purchases for this user (for has_purchased check) — + // course_purchases now spans all three content types, so filter down to + // course-targeted products here. const myPurchases = await mdl_CoursePurchase.findAll({ where: { user_id: req.user.user_id, status: 'completed' }, - include: [{ model: mdl_Product, as: 'product', attributes: ['course_id', 'access_days'] }], + include: [{ model: mdl_Product, as: 'product', attributes: ['purchasable_type', 'purchasable_id', 'access_days'] }], attributes: ['id', 'expires_at', 'product_id'], }); const purchasedCourseIds = new Set( myPurchases - .filter((p) => !p.expires_at || new Date(p.expires_at) > new Date()) - .map((p) => String(p.product?.course_id)) + .filter((p) => p.product?.purchasable_type === 'course' && (!p.expires_at || new Date(p.expires_at) > new Date())) + .map((p) => String(p.product.purchasable_id)) ); // Build category filter @@ -506,7 +532,7 @@ exports.getCourse = async (req, res) => { // Attach product info and purchase status for the buy-course flow const product = await mdl_Product.findOne({ - where: { course_id: courseId, is_active: true }, + where: { purchasable_type: 'course', purchasable_id: courseId, is_active: true }, attributes: ['id', 'name', 'price', 'currency', 'access_days'], }); const hasPurchase = product && await mdl_CoursePurchase.findOne({ @@ -1322,7 +1348,7 @@ exports.getLessonsByUnitUuid = async (req, res) => { const { uuid } = req.params; const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, - attributes: ["unit_id", "uuid", "title", "description", "duration_seconds"], + attributes: ["unit_id", "uuid", "title", "subscription", "description", "duration_seconds"], include: [ { model: Course, as: "courses", @@ -1353,10 +1379,12 @@ 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 } = 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 }, }); } @@ -1446,7 +1474,7 @@ exports.getLessonByUuid = async (req, res) => { const { uuid } = req.params; const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, - attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"], + attributes: ["lesson_id", "uuid", "title", "subscription", "description", "duration_seconds"], include: [ { model: LessonPage, @@ -1480,10 +1508,12 @@ 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 } = 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 }, }); } @@ -1527,4 +1557,69 @@ exports.getLessonByUuid = async (req, res) => { console.error("[CLIENT][LESSONS][BY UUID]", err); return R.error(res, "Could not retrieve lesson.", 500); } +}; + +// ─── 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" }; + +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 }, + attributes: ["id", "name", "price", "currency", "access_days"], + }); + const hasPurchase = product && await mdl_CoursePurchase.findOne({ + where: { + user_id, product_id: product.id, status: "completed", + [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], + }, + }); + return { product: product ?? null, has_purchased: !!hasPurchase }; +} + +exports.getCourseCheckoutInfo = async (req, res) => { + try { + const { courseId } = req.params; + const course = await Course.findOne({ + where: { course_id: courseId, ...notDeleted, status: "published" }, + attributes: ["course_id", "uuid", "title", "description", "level", "subscription"], + }); + if (!course) return R.error(res, "Course not found.", 404); + const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "course", course); + return R.success(res, "Checkout info retrieved.", { ...course.toJSON(), product, has_purchased }); + } catch (err) { + 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"] }); + if (!unit) return R.error(res, "Unit not found.", 404); + const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "unit", unit); + return R.success(res, "Checkout info retrieved.", { ...unit.toJSON(), product, has_purchased }); + } 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"] }); + if (!lesson) return R.error(res, "Lesson not found.", 404); + const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson); + return R.success(res, "Checkout info retrieved.", { ...lesson.toJSON(), product, has_purchased }); + } 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 f20692e..01f06eb 100644 --- a/controllers/client/profile.controller.js +++ b/controllers/client/profile.controller.js @@ -21,6 +21,7 @@ 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'); // ─── GET own profile ─────────────────────────────────────────────────────────── @@ -29,7 +30,7 @@ exports.getProfile = async (req, res) => { const user = await mdl_Users.findByPk(req.user.user_id, { attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, }); - return R.success(res, 'Profile retrieved.', user); + return R.success(res, 'Profile retrieved.', await resolveUserAvatar(user)); } catch (err) { return R.error(res, 'Could not retrieve profile.', 500); } @@ -60,7 +61,7 @@ exports.updateProfile = async (req, res) => { logActivity(req.user.user_id, 'update_profile'); - return R.success(res, 'Profile updated.', updated); + return R.success(res, 'Profile updated.', await resolveUserAvatar(updated)); } catch (err) { console.error('[CLIENT] updateProfile error:', err); return R.error(res, 'Profile update failed.', 500); @@ -140,7 +141,7 @@ exports.uploadAvatar = async (req, res) => { const updated = await mdl_Users.findByPk(req.user.user_id, { attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, }); - return R.success(res, 'Avatar updated.', updated); + return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated)); } catch (err) { console.error('[CLIENT] uploadAvatar error:', err); return R.error(res, 'Avatar upload failed.', 500); diff --git a/controllers/client/tiers.controller.js b/controllers/client/tiers.controller.js index d7236e5..7c8786f 100644 --- a/controllers/client/tiers.controller.js +++ b/controllers/client/tiers.controller.js @@ -184,12 +184,13 @@ exports.createOrder = async (req, res) => { const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); if (!plan) return R.error(res, 'Plan not found or inactive.', 404); + // Repurchasing a plan under a tier already held active is allowed — it + // extends the existing grant's expires_at (see captureOrder) rather than + // being blocked. Surfaced here only for checkout-page messaging. const existingActive = await mdl_UserTiers.findOne({ where: { user_id: req.user.user_id, tier: plan.tier, status: 'active' }, + attributes: ['expires_at'], }); - if (existingActive) { - return R.error(res, `You already have an active ${plan.tier} subscription until ${existingActive.expires_at}. You can repurchase once it expires.`, 409); - } const effectivePrice = Number(plan.price); const effectiveCurrency = plan.currency; @@ -242,13 +243,15 @@ exports.createOrder = async (req, res) => { }); return R.success(res, 'Order created.', { - payment_id: payment.payment_id, - order_id: ppOrder.id, - approval_url: approvalUrl, - amount: total, - currency: effectiveCurrency, - promo_code: promoResult.code, - discount: discount.toFixed(2), + payment_id: payment.payment_id, + order_id: ppOrder.id, + approval_url: approvalUrl, + amount: total, + currency: effectiveCurrency, + promo_code: promoResult.code, + discount: discount.toFixed(2), + extends_existing: !!existingActive, + current_expires_at: existingActive?.expires_at ?? null, }, 201); } catch (err) { console.error('[CLIENT][CREATE ORDER]', err); @@ -297,47 +300,44 @@ exports.captureOrder = async (req, res) => { const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0]; - // Scoped, defensive re-check: createOrder already blocked this, but time may - // have passed (or two checkout tabs raced) between order creation and capture. - // Money has already moved via PayPal at this point, so auto-refund rather than - // leaving the user charged with nothing to show for it. + // Repurchasing a plan under a tier already held active extends the + // existing grant's expires_at by the new plan's duration, rather than + // being blocked/refunded — the original plan_id is kept (whichever plan + // first granted this tier keeps governing its bundle/access_rules; a + // sibling-plan repurchase only adds time). This also keeps the + // one-active-row-per-(user,tier) DB invariant intact, since no second + // row is ever created. const existingActive = await mdl_UserTiers.findOne({ where: { user_id: req.user.user_id, tier: payment.plan.tier, status: 'active' }, }); + + let resultTier; + let successMessage; + if (existingActive) { - try { - await paymentSvc.refundCapture(payment.provider, capture?.id, payment.amount, payment.currency); - await payment.update({ - status: 'refunded', - provider_payload: { ...payment.provider_payload, capture_id: capture?.id, capture: captureData, error: 'duplicate_active_tier_auto_refunded' }, - }); - return R.error(res, `You already have an active ${payment.plan.tier} subscription. Your payment has been automatically refunded.`, 409); - } catch (refundErr) { - console.error('[CLIENT][CAPTURE ORDER] auto-refund failed for duplicate active tier:', refundErr?.response?.data ?? refundErr); - await payment.update({ - status: 'failed', - provider_payload: { ...payment.provider_payload, capture_id: capture?.id, capture: captureData, error: 'duplicate_active_tier_refund_failed' }, - }); - return R.error(res, `You already have an active ${payment.plan.tier} subscription. Refund could not be processed automatically — please contact support.`, 409); - } + const newExpiresAt = new Date(existingActive.expires_at.getTime() + payment.plan.duration_days * 86400000); + await existingActive.update({ expires_at: newExpiresAt }); + resultTier = existingActive; + successMessage = `Payment successful. Your ${payment.plan.tier} access has been extended to ${newExpiresAt.toLocaleDateString()}.`; + } else { + const startsAt = new Date(); + const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000); + + resultTier = await mdl_UserTiers.create({ + user_id: req.user.user_id, + tier: payment.plan.tier, + plan_id: payment.plan_id, + status: 'active', + starts_at: startsAt, + expires_at: expiresAt, + granted_by: null, + }); + successMessage = 'Payment successful. Tier activated.'; } - const startsAt = new Date(); - const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000); - - const newTier = await mdl_UserTiers.create({ - user_id: req.user.user_id, - tier: payment.plan.tier, - plan_id: payment.plan_id, - status: 'active', - starts_at: startsAt, - expires_at: expiresAt, - granted_by: null, - }); - await payment.update({ status: 'completed', - tier_id: newTier.tier_id, + tier_id: resultTier.tier_id, paid_at: new Date(), provider_payload: { ...payment.provider_payload, @@ -347,11 +347,14 @@ exports.captureOrder = async (req, res) => { }, }); - await onTierActivated(req.user.user_id, newTier.tier); + // Idempotent (grantAchievement checks for an existing row first) — safe + // to call again on an extension, won't grant a duplicate achievement. + await onTierActivated(req.user.user_id, resultTier.tier); - return R.success(res, 'Payment successful. Tier activated.', { - tier: newTier.tier, - expires_at: newTier.expires_at, + return R.success(res, successMessage, { + tier: resultTier.tier, + expires_at: resultTier.expires_at, + extended: !!existingActive, }); } catch (err) { console.error('[CLIENT][CAPTURE ORDER]', err); diff --git a/controllers/client/units.controller.js b/controllers/client/units.controller.js index f76df6d..0126404 100644 --- a/controllers/client/units.controller.js +++ b/controllers/client/units.controller.js @@ -30,9 +30,12 @@ ***********************************************************************************************************************************************************************/ "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, @@ -61,6 +64,35 @@ 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 @@ -104,6 +136,8 @@ 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. @@ -112,7 +146,13 @@ 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; - result.push({ ...row, courses: coursesByUnit.get(row.unit_id) ?? [], is_locked }); + result.push({ + ...row, + courses: coursesByUnit.get(row.unit_id) ?? [], + is_locked, + product: productById.get(String(row.unit_id)) ?? null, + has_purchased: purchasedIds.has(String(row.unit_id)), + }); } return R.success(res, "Units retrieved.", result); @@ -131,7 +171,7 @@ exports.getLessons = async (req, res) => { try { const rows = await sequelize.query(` SELECT - l.lesson_id, l.uuid, l.title, l.description, l.duration_seconds, + l.lesson_id, l.uuid, l.title, l.subscription, l.description, l.duration_seconds, (SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL WHERE ul.lesson_id = l.lesson_id) AS unit_count @@ -158,15 +198,23 @@ exports.getLessons = async (req, res) => { coursesByLesson.set(row.lesson_id, list); } - // is_locked mirrors canAccessLesson: standalone/unattached lessons are - // open, attached lessons need at least one accessible course through - // any attached unit. + 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. const result = []; for (const row of rows) { - const is_locked = 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)) : false; - result.push({ ...row, courses: coursesByLesson.get(row.lesson_id) ?? [], is_locked }); + result.push({ + ...row, + courses: coursesByLesson.get(row.lesson_id) ?? [], + is_locked, + product: productById.get(String(row.lesson_id)) ?? null, + has_purchased: purchasedIds.has(String(row.lesson_id)), + }); } return R.success(res, "Lessons retrieved.", result); @@ -481,3 +529,5 @@ 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/cron/admin.cron.js b/cron/admin.cron.js index 5e1a497..c22dae8 100644 --- a/cron/admin.cron.js +++ b/cron/admin.cron.js @@ -23,6 +23,7 @@ * Currently registered: * - taskOverdue (cron/jobs/task_overdue.cron.js) — settings-backed * - liftExpiredBans (cron/jobs/lift_expired_bans.cron.js) + * - retryStuckTranscodes (cron/jobs/retry_stuck_transcodes.cron.js) * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 17, 2026 @@ -30,6 +31,7 @@ const cron = require('node-cron'); const taskOverdue = require('./jobs/task_overdue.cron'); const liftExpiredBans = require('./jobs/lift_expired_bans.cron'); +const retryStuckTranscodes = require('./jobs/retry_stuck_transcodes.cron'); const { startSettingsBackedJobs } = require('./cronRegistry.util'); // ─── Registry — add future admin-side cron jobs here ───────────────────────── @@ -40,6 +42,7 @@ const settingsBackedJobs = [ // Plain hardcoded-schedule jobs (not tied to any notification setting). const plainJobs = [ liftExpiredBans, + retryStuckTranscodes, ]; // ─── Boot all registered admin-side jobs ────────────────────────────────────── diff --git a/cron/jobs/retry_stuck_transcodes.cron.js b/cron/jobs/retry_stuck_transcodes.cron.js new file mode 100644 index 0000000..8ba0b92 --- /dev/null +++ b/cron/jobs/retry_stuck_transcodes.cron.js @@ -0,0 +1,65 @@ +/*********************************************************************************************************************************************************************** + * File Name : retry_stuck_transcodes.cron.js + * Type : Cron Job + * Description : Safety net for the .mov/.mkv -> faststart .mp4 background + * remux (see services/assetTranscode.service.js). Picks up: + * - "pending" — the fire-and-forget call in + * assets.controller.js#finalizeAssetFromStorage + * never actually started (e.g. this process + * crashed between the DB commit and the call). + * - "processing" for over 30 minutes — the job itself was + * running when the process restarted/crashed + * mid-remux and never got to flip the status. + * + * Processes at most 3 per run, sequentially — this runs on a + * small droplet, and remuxing is disk/CPU-bound; no reason to + * pile up concurrent ffmpeg processes for a background sweep. + * + * Schedule : Every 10 minutes ("*\/10 * * * *"). Registered by + * cron/admin.cron.js. + * + * Author: Kenneth Obsequio + * Date Created: Aug. 1, 2026 + ***********************************************************************************************************************************************************************/ +const { Op } = require('sequelize'); +const mdl_Assets = require('../../models/assets/assets.mdl'); +const { transcodeAsset } = require('../../services/assetTranscode.service'); + +const MAX_PER_RUN = 3; +const STUCK_PROCESSING_MINUTES = 30; + +async function run() { + try { + const stuckSince = new Date(Date.now() - STUCK_PROCESSING_MINUTES * 60 * 1000); + + // Demote stale "processing" rows back to "pending" so transcodeAsset()'s + // own claim step (pending/failed -> processing) can pick them up again — + // it never claims an in-progress "processing" row, by design (avoids + // double-processing a job that's actually still running elsewhere). + await mdl_Assets.update( + { transcode_status: 'pending' }, + { where: { transcode_status: 'processing', updatedAt: { [Op.lt]: stuckSince } } }, + ); + + const candidates = await mdl_Assets.findAll({ + where: { deletedAt: null, transcode_status: 'pending' }, + limit: MAX_PER_RUN, + }); + + if (!candidates.length) return; + + for (const asset of candidates) { + await transcodeAsset(asset); + } + + console.log(`[CRON][RETRY STUCK TRANSCODES] Processed ${candidates.length} asset(s).`); + } catch (err) { + console.error('[CRON][RETRY STUCK TRANSCODES] Failed:', err); + } +} + +module.exports = { + name: 'retryStuckTranscodes', + schedule: '*/10 * * * *', + run, +}; diff --git a/database/migrations/20270101000076-add-assets-transcode-status.js b/database/migrations/20270101000076-add-assets-transcode-status.js new file mode 100644 index 0000000..6d343ce --- /dev/null +++ b/database/migrations/20270101000076-add-assets-transcode-status.js @@ -0,0 +1,43 @@ +'use strict'; + +// Tracks background container-remux jobs for video assets uploaded as .mov/ +// .mkv — those formats often load slowly in-browser (moov/Cues index at the +// end of the file) compared to faststart .mp4. See services/ffmpeg.service.js +// + services/assetTranscode.service.js. +// +// STRING + explicit CHECK instead of a real ENUM — same CockroachDB +// constraint noted in 20270101000032-create-advertisements.js (addColumn +// can't create a new enum type the way createTable can). +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('assets', 'transcode_status', { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'none', + }); + + await queryInterface.addColumn('assets', 'transcode_error', { + type: Sequelize.TEXT, + allowNull: true, + }); + + await queryInterface.addConstraint('assets', { + fields: ['transcode_status'], + type: 'check', + name: 'check_assets_transcode_status', + where: { transcode_status: { [Sequelize.Op.in]: ['none', 'pending', 'processing', 'done', 'failed'] } }, + }); + + await queryInterface.sequelize.query(` + CREATE INDEX idx_assets_transcode_status + ON assets (transcode_status) + WHERE transcode_status IN ('pending', 'processing'); + `); + }, + + async down(queryInterface) { + await queryInterface.removeConstraint('assets', 'check_assets_transcode_status'); + await queryInterface.removeColumn('assets', 'transcode_status'); + await queryInterface.removeColumn('assets', 'transcode_error'); + }, +}; diff --git a/database/migrations/20270101000077-add-lessons-subscription.js b/database/migrations/20270101000077-add-lessons-subscription.js new file mode 100644 index 0000000..f929746 --- /dev/null +++ b/database/migrations/20270101000077-add-lessons-subscription.js @@ -0,0 +1,18 @@ +'use strict'; + +// Standalone tier gate for Lessons, mirroring units.subscription (see +// 20270101000012-create-units.js) — null means open (or gated only via an +// attached unit/course). Full parity with the Unit-level gate added for the +// standalone Units/Lessons tier-gating extension. +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('lessons', 'subscription', { + type: Sequelize.STRING(50), + allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('lessons', 'subscription'); + }, +}; diff --git a/database/migrations/20270101000078-generalize-products-purchasable.js b/database/migrations/20270101000078-generalize-products-purchasable.js new file mode 100644 index 0000000..1f03354 --- /dev/null +++ b/database/migrations/20270101000078-generalize-products-purchasable.js @@ -0,0 +1,81 @@ +'use strict'; + +// Generalizes `products` from Course-only (`course_id`) to a polymorphic +// target (`purchasable_type` + `purchasable_id`) so standalone Units and +// Lessons can also be sold individually, alongside plan-tier gating — see +// utils/purchasable.util.js for the resolver used by every consumer. +// +// purchasable_type is STRING + explicit CHECK, not a real ENUM (CockroachDB +// can't create a new enum type via addColumn the way createTable can — same +// caveat as 20270101000076-add-assets-transcode-status.js). +// +// Dropping `course_id` also drops its FK (`onDelete: 'CASCADE'` from +// 20270101000019-create-products.js) — referential integrity across the three +// possible targets is now enforced at the application layer only, same as +// course_purchases.product_id already is today. +// +// Touches a table with live production rows (real courses have real +// products/course_purchases). NOT auto-run — test against a local/staging +// copy of the DB first; do not execute against the shared dev/prod database +// without explicit confirmation. +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('products', 'purchasable_type', { + type: Sequelize.STRING(10), + allowNull: false, + defaultValue: 'course', + }); + + await queryInterface.addConstraint('products', { + fields: ['purchasable_type'], + type: 'check', + name: 'check_products_purchasable_type', + where: { purchasable_type: { [Sequelize.Op.in]: ['course', 'unit', 'lesson'] } }, + }); + + await queryInterface.addColumn('products', 'purchasable_id', { + type: Sequelize.BIGINT, + allowNull: true, // filled by the backfill below, then locked to NOT NULL + }); + + await queryInterface.sequelize.query(` + UPDATE products SET purchasable_type = 'course', purchasable_id = course_id; + `); + + await queryInterface.changeColumn('products', 'purchasable_id', { + type: Sequelize.BIGINT, + allowNull: false, + }); + + await queryInterface.removeIndex('products', ['course_id']); + await queryInterface.removeColumn('products', 'course_id'); + + await queryInterface.addIndex('products', ['purchasable_type', 'purchasable_id'], { + name: 'products_purchasable_type_id', + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeIndex('products', 'products_purchasable_type_id'); + + await queryInterface.addColumn('products', 'course_id', { + type: Sequelize.BIGINT, + allowNull: true, + }); + + await queryInterface.sequelize.query(` + UPDATE products SET course_id = purchasable_id WHERE purchasable_type = 'course'; + `); + + await queryInterface.changeColumn('products', 'course_id', { + type: Sequelize.BIGINT, + allowNull: false, + }); + + await queryInterface.addIndex('products', ['course_id']); + + await queryInterface.removeColumn('products', 'purchasable_id'); + await queryInterface.removeConstraint('products', 'check_products_purchasable_type'); + await queryInterface.removeColumn('products', 'purchasable_type'); + }, +}; diff --git a/database/migrations/20270101000079-create-plan-units.js b/database/migrations/20270101000079-create-plan-units.js new file mode 100644 index 0000000..6e54f73 --- /dev/null +++ b/database/migrations/20270101000079-create-plan-units.js @@ -0,0 +1,19 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('plan_units', { + id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + plan_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'tier_plans', key: 'plan_id' }, onDelete: 'CASCADE' }, + unit_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addIndex('plan_units', ['plan_id']); + }, + + async down(queryInterface) { + await queryInterface.dropTable('plan_units'); + }, +}; diff --git a/database/migrations/20270101000080-create-plan-lessons.js b/database/migrations/20270101000080-create-plan-lessons.js new file mode 100644 index 0000000..7a0f75e --- /dev/null +++ b/database/migrations/20270101000080-create-plan-lessons.js @@ -0,0 +1,19 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('plan_lessons', { + id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + plan_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'tier_plans', key: 'plan_id' }, onDelete: 'CASCADE' }, + lesson_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addIndex('plan_lessons', ['plan_id']); + }, + + async down(queryInterface) { + await queryInterface.dropTable('plan_lessons'); + }, +}; diff --git a/models/assets/assets.mdl.js b/models/assets/assets.mdl.js index 54d25af..c28cf17 100644 --- a/models/assets/assets.mdl.js +++ b/models/assets/assets.mdl.js @@ -39,6 +39,13 @@ const Asset = sequelize.define("Asset", { thumbnail_url: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true }, thumbnail_storage_key: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true }, + // ─── Background remux (.mov/.mkv → faststart .mp4) ─────────────────────── + // "none" — asset never needed a remux (not video, or already a fast format). + // STRING, not ENUM — matches the STRING+CHECK column (see migration + // 20270101000076), not a real Postgres enum type. + transcode_status: { type: DataTypes.STRING(20), defaultValue: "none", label: "", order: 0, hidden: true }, + transcode_error: { type: DataTypes.TEXT, label: "", order: 0, hidden: true }, + // ─── Description ────────────────────────────────────────────────────────── description: { type: DataTypes.TEXT, label: "Description", hidden: true }, diff --git a/models/courses/lessons.mdl.js b/models/courses/lessons.mdl.js index bd546b2..587a511 100644 --- a/models/courses/lessons.mdl.js +++ b/models/courses/lessons.mdl.js @@ -9,6 +9,7 @@ const Lesson = sequelize.define("Lesson", { title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1, filterable: true }, // order 2 is reserved for the computed "Affiliated" (course_count) column, order 3 for computed "Course Status" — see LESSON_LIST_COMPUTED in lessons.controller.js + subscription: { type: DataTypes.STRING(50), allowNull: true, label: "Subscription", hidden: false, order: 4, filterable: true, comment: "Optional direct tier gate for standalone lessons — null means open (or gated only via an attached unit)." }, description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 0, filterable: false }, duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, filterable: false }, // computed from blocks on save diff --git a/models/courses/products.mdl.js b/models/courses/products.mdl.js index b78af37..5a5dd3a 100644 --- a/models/courses/products.mdl.js +++ b/models/courses/products.mdl.js @@ -2,10 +2,22 @@ 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 +// utils/purchasable.util.js for the type -> model/checkout-path resolver used +// by every consumer (access checks, admin CRUD, checkout). +// +// constraints: false below because purchasable_id doesn't point at a single +// table — referential integrity across the three possible targets is +// enforced at the application layer only (see +// 20270101000078-generalize-products-purchasable.js). const mdl_Product = sequelize.define('Product', { - id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, - course_id: { type: DataTypes.BIGINT, allowNull: false }, + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + purchasable_type: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'course' }, + purchasable_id: { type: DataTypes.BIGINT, allowNull: false }, name: { type: DataTypes.STRING(200), allowNull: false }, description: { type: DataTypes.TEXT, allowNull: true }, price: { type: DataTypes.DECIMAL(10, 2), allowNull: false }, @@ -18,8 +30,12 @@ const mdl_Product = sequelize.define('Product', { paranoid: true, }); -// A course has one product listing; a product belongs to one course -mdl_Product.belongsTo(Course, { foreignKey: 'course_id', as: 'course' }); -Course.hasOne(mdl_Product, { foreignKey: 'course_id', as: 'product' }); +// 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. +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/models/tiers/plan_lessons.mdl.js b/models/tiers/plan_lessons.mdl.js new file mode 100644 index 0000000..e07a1b8 --- /dev/null +++ b/models/tiers/plan_lessons.mdl.js @@ -0,0 +1,24 @@ +/*********************************************************************************************************************************************************************** + * File Name: plan_lessons.mdl.js + * Type of Program: Model + * Description: Junction table — links standalone Lessons to a specific tier plan. + * UNIQUE on lesson_id enforces one lesson belongs to one plan only. + * Mirrors plan_courses.mdl.js — bundling/display only, not an + * access-control mechanism (see canAccessLesson). + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Aug. 1, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_PlanLessons = sequelize.define('PlanLesson', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + plan_id: { type: DataTypes.BIGINT, allowNull: false }, + lesson_id: { type: DataTypes.BIGINT, allowNull: false }, +}, { + tableName: 'plan_lessons', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_PlanLessons; diff --git a/models/tiers/plan_units.mdl.js b/models/tiers/plan_units.mdl.js new file mode 100644 index 0000000..29c4eec --- /dev/null +++ b/models/tiers/plan_units.mdl.js @@ -0,0 +1,24 @@ +/*********************************************************************************************************************************************************************** + * File Name: plan_units.mdl.js + * Type of Program: Model + * Description: Junction table — links standalone Units to a specific tier plan. + * UNIQUE on unit_id enforces one unit belongs to one plan only. + * Mirrors plan_courses.mdl.js — bundling/display only, not an + * access-control mechanism (see canAccessUnit). + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Aug. 1, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_PlanUnits = sequelize.define('PlanUnit', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + plan_id: { type: DataTypes.BIGINT, allowNull: false }, + unit_id: { type: DataTypes.BIGINT, allowNull: false }, +}, { + tableName: 'plan_units', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_PlanUnits; diff --git a/models/tiers/tier.associations.js b/models/tiers/tier.associations.js index d5262a5..d4b42a7 100644 --- a/models/tiers/tier.associations.js +++ b/models/tiers/tier.associations.js @@ -4,10 +4,14 @@ const mdl_TierPlans = require('./tier_plans.mdl'); const mdl_UserTiers = require('./user_tiers.mdl'); const mdl_Payments = require('./payments.mdl'); const mdl_PlanCourses = require('./plan_courses.mdl'); +const mdl_PlanUnits = require('./plan_units.mdl'); +const mdl_PlanLessons = require('./plan_lessons.mdl'); const mdl_PlanPolicies = require('./plan_policies.mdl'); const mdl_SystemBadges = require('../system_badges/system_badges.mdl'); const Asset = require('../assets/assets.mdl'); const { Course } = require('../courses/courses.mdl'); +const Unit = require('../courses/units.mdl'); +const Lesson = require('../courses/lessons.mdl'); // ─── TierCategory ───────────────────────────────────────────────────────────── mdl_TierCategories.belongsTo(Asset, { foreignKey: 'badge_asset_id', as: 'badgeAsset' }); @@ -45,6 +49,42 @@ mdl_PlanCourses.belongsTo(Course, { foreignKey: 'course_id', as: 'course' Course.hasOne(mdl_PlanCourses, { as: 'planCourse', foreignKey: 'course_id' }); mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_id' }); +// ─── Plan ↔ Units ───────────────────────────────────────────────────────────── +mdl_TierPlans.belongsToMany(Unit, { + through: mdl_PlanUnits, + foreignKey: 'plan_id', + otherKey: 'unit_id', + as: 'units', +}); +Unit.belongsToMany(mdl_TierPlans, { + through: mdl_PlanUnits, + foreignKey: 'unit_id', + otherKey: 'plan_id', + as: 'plans', +}); +mdl_PlanUnits.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); +mdl_PlanUnits.belongsTo(Unit, { foreignKey: 'unit_id', as: 'unit' }); +Unit.hasOne(mdl_PlanUnits, { as: 'planUnit', foreignKey: 'unit_id' }); +mdl_TierPlans.hasMany(mdl_PlanUnits, { as: 'planUnits', foreignKey: 'plan_id' }); + +// ─── Plan ↔ Lessons ─────────────────────────────────────────────────────────── +mdl_TierPlans.belongsToMany(Lesson, { + through: mdl_PlanLessons, + foreignKey: 'plan_id', + otherKey: 'lesson_id', + as: 'lessons', +}); +Lesson.belongsToMany(mdl_TierPlans, { + through: mdl_PlanLessons, + foreignKey: 'lesson_id', + otherKey: 'plan_id', + as: 'plans', +}); +mdl_PlanLessons.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); +mdl_PlanLessons.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesson' }); +Lesson.hasOne(mdl_PlanLessons, { as: 'planLesson', foreignKey: 'lesson_id' }); +mdl_TierPlans.hasMany(mdl_PlanLessons, { as: 'planLessons', foreignKey: 'plan_id' }); + // ─── UserTier → Plan ────────────────────────────────────────────────────────── mdl_UserTiers.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' }); @@ -62,6 +102,8 @@ module.exports = { mdl_UserTiers, mdl_Payments, mdl_PlanCourses, + mdl_PlanUnits, + mdl_PlanLessons, mdl_PlanPolicies, mdl_SystemBadges, }; diff --git a/routes/admin/lessons.routes.js b/routes/admin/lessons.routes.js index c993a90..6c61041 100644 --- a/routes/admin/lessons.routes.js +++ b/routes/admin/lessons.routes.js @@ -18,6 +18,7 @@ const requirementsCtrl = require("../../controllers/admin/completion_requirement router.get("/", ctrl.getLessons); router.post("/", ctrl.createLesson); router.get("/flat", ctrl.getLessonsFlat); +router.get("/by-subscription", ctrl.getLessonsBySubscription); router.get("/field-values", ctrl.getLessonFieldValues); router.delete("/bulk", ctrl.bulkArchiveLessons); router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteLessons); diff --git a/routes/admin/products.routes.js b/routes/admin/products.routes.js index 4399c0a..b2a26b5 100644 --- a/routes/admin/products.routes.js +++ b/routes/admin/products.routes.js @@ -7,6 +7,16 @@ 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/admin/tiers.routes.js b/routes/admin/tiers.routes.js index e67518b..7f24d64 100644 --- a/routes/admin/tiers.routes.js +++ b/routes/admin/tiers.routes.js @@ -18,11 +18,15 @@ router.get ('/users/:id/tiers', ctrl.getUserTiers); router.post ('/users/tiers/grant', ctrl.grantTier); router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier); -// ← course + impact routes before /:id +// ← course/unit/lesson + impact routes before /:id router.get ('/:id/impact', ctrl.getPlanImpact); router.get ('/:id/permanent-delete-impact', ctrl.getPlanPermanentDeleteImpact); router.get ('/:id/courses', ctrl.getPlanCourses); router.post ('/:id/courses', ctrl.syncPlanCourses); +router.get ('/:id/units', ctrl.getPlanUnits); +router.post ('/:id/units', ctrl.syncPlanUnits); +router.get ('/:id/lessons', ctrl.getPlanLessons); +router.post ('/:id/lessons', ctrl.syncPlanLessons); router.get ('/:id', ctrl.getPlan); router.put ('/:id', ctrl.updatePlan); diff --git a/routes/admin/units.routes.js b/routes/admin/units.routes.js index 1e66004..65b7cba 100644 --- a/routes/admin/units.routes.js +++ b/routes/admin/units.routes.js @@ -20,6 +20,7 @@ router.get("/", ctrl.getUnits); router.post("/", ctrl.createUnit); router.post("/full", ctrl.createUnitFull); router.get("/flat", ctrl.getUnitsFlat); +router.get("/by-subscription", ctrl.getUnitsBySubscription); router.get("/field-values", ctrl.getUnitFieldValues); router.delete("/bulk", ctrl.bulkArchiveUnits); router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteUnits); diff --git a/routes/client/courses.routes.js b/routes/client/courses.routes.js index 187597d..1a00bec 100644 --- a/routes/client/courses.routes.js +++ b/routes/client/courses.routes.js @@ -23,6 +23,7 @@ router.get('/quiz/uuid/:uuid', ctrl.getQuizByUuid); // Courses router.get('/', ctrl.getCourses); +router.get('/:courseId/checkout-info', ctrl.getCourseCheckoutInfo); router.get('/:courseId', ctrl.getCourse); // Unit diff --git a/routes/client/lessons.routes.js b/routes/client/lessons.routes.js index a0fbfb7..4c93c61 100644 --- a/routes/client/lessons.routes.js +++ b/routes/client/lessons.routes.js @@ -16,6 +16,7 @@ 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 176a833..a47f44e 100644 --- a/routes/client/units.routes.js +++ b/routes/client/units.routes.js @@ -19,6 +19,7 @@ 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/services/assetTranscode.service.js b/services/assetTranscode.service.js new file mode 100644 index 0000000..f95e4a9 --- /dev/null +++ b/services/assetTranscode.service.js @@ -0,0 +1,82 @@ +// services/assetTranscode.service.js +// +// Orchestrates the background remux job for a single video asset: mints a +// presigned read URL, hands it to ffmpeg.service, uploads the result back to +// storage (streamed, not buffered), then swaps the asset row over to the new +// object. Called two ways: +// 1. Fire-and-forget from assets.controller.js#finalizeAssetFromStorage, +// right after a .mov/.mkv upload is finalized. +// 2. cron/jobs/retry_stuck_transcodes.cron.js — safety net for jobs that +// never got picked up (server restarted mid-remux) or are still marked +// "pending" (the fire-and-forget call in 1. never actually started, +// e.g. this process crashed between the DB commit and the call). +// +// The original .mov/.mkv object is intentionally left in storage on success +// — this only swaps which object the asset *plays from* (storage_key), it +// doesn't delete anything. Reclaiming that storage is a separate decision. + +const fs = require("fs"); + +const Asset = require("../models/assets/assets.mdl"); +const s3 = require("../services/s3.service"); +const ffmpegSvc = require("../services/ffmpeg.service"); + +// ── One retry-worth of guardrails ────────────────────────────────────────── +// Only ever the fire-and-forget call or the retry cron should be racing to +// pick up a given asset — this claim step (pending/failed -> processing) +// makes double-processing harmless even if both fire close together. +async function claimForProcessing(assetId) { + const [count] = await Asset.update( + { transcode_status: "processing", transcode_error: null }, + { where: { asset_id: assetId, transcode_status: ["pending", "failed"] } }, + ); + return count > 0; +} + +async function transcodeAsset(asset) { + if (asset.storage_provider !== "s3" || !ffmpegSvc.needsRemux(asset.extension)) return; + + const claimed = await claimForProcessing(asset.asset_id); + if (!claimed) return; // already being processed, or already done + + let outputPath = null; + try { + const inputUrl = await s3.getSignedDownloadUrl(asset.storage_key); + outputPath = await ffmpegSvc.remuxToFaststartMp4(inputUrl); + + const { size: file_size } = await fs.promises.stat(outputPath); + const readStream = fs.createReadStream(outputPath); + + const originalname = `${(asset.original_name || asset.uuid || "video").replace(/\.[^.]+$/, "")}.mp4`; + const { url: file_url, uuid: storage_key } = await s3.uploadStream({ + stream: readStream, + originalname, + mimetype: "video/mp4", + ownerType: "video", + }); + + await asset.update({ + storage_key, + file_url, + file_size, + mime_type: "video/mp4", + extension: "mp4", + transcode_status: "done", + transcode_error: null, + }); + + console.log(`[ASSET][TRANSCODE] Remuxed asset ${asset.asset_id} (${asset.original_name}) to faststart mp4.`); + + } catch (err) { + console.error(`[ASSET][TRANSCODE] Remux failed for asset ${asset.asset_id}:`, err.message); + await asset.update({ + transcode_status: "failed", + transcode_error: String(err.message || err).slice(0, 2000), + }).catch(() => {}); + + } finally { + if (outputPath) fs.promises.unlink(outputPath).catch(() => {}); + } +} + +module.exports = { transcodeAsset }; diff --git a/services/ffmpeg.service.js b/services/ffmpeg.service.js new file mode 100644 index 0000000..525242a --- /dev/null +++ b/services/ffmpeg.service.js @@ -0,0 +1,102 @@ +// services/ffmpeg.service.js +// +// Fast container remux for video assets whose original format loads slowly +// in the browser. Not a transcode — the video/audio streams are copied +// byte-for-byte (`-c copy`), only the container is swapped: +// +// .mov — often exported without "fast start" (common for OBS/QuickTime +// screen recordings), which puts the moov atom — the index the +// browser needs before it can render anything — at the END of the +// file. Playback can't begin until that's reached. +// .mkv — same class of problem with its Cues index, plus native browser +// support for Matroska demuxing/seeking is inconsistent to begin +// with. +// +// Remuxing into a faststart .mp4 (moov moved to the front) makes both play +// exactly like this app's already-fast .mp4 uploads. .mp4/.mp3 are untouched +// — they don't have this problem. +// +// Reads directly from a presigned S3 URL — ffmpeg's own HTTP client handles +// that (same as ffprobe.service.js's probeUrl()), this process never buffers +// the original file. The mp4 muxer needs a seekable *output* to rewrite the +// moov atom after the fact, so the result is written to a local temp file — +// see services/assetTranscode.service.js for streaming that back to storage +// without buffering it into memory either. + +const ffmpeg = require("fluent-ffmpeg"); +const ffprobeStatic = require("ffprobe-static"); +const os = require("os"); +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); + +try { + const { execSync } = require("child_process"); + execSync("which ffprobe", { stdio: "ignore" }); +} catch { + ffmpeg.setFfprobePath(ffprobeStatic.path); +} + +const REMUXABLE_EXTENSIONS = new Set(["mov", "mkv"]); + +function needsRemux(extension = "") { + return REMUXABLE_EXTENSIONS.has((extension || "").toLowerCase()); +} + +function tempOutputPath() { + return path.join(os.tmpdir(), `remux_${Date.now()}_${crypto.randomBytes(4).toString("hex")}.mp4`); +} + +// input: presigned GET URL for the original .mov/.mkv object +// output: local filesystem path to the remuxed .mp4 (caller owns cleanup) +// +// -map 0:v:0 -map 0:a:0? — take the first video stream and, if present, the +// first audio stream only. Drops subtitle/data streams some mkv/mov files +// carry, which the mp4 muxer either can't hold or chokes on. +// -max_muxing_queue_size — defensive bump; large copy-remuxes of files with +// bursty interleaving can otherwise hit "Too many packets buffered for +// output stream" and abort. +function remuxToFaststartMp4(inputUrl, { timeoutMs = 30 * 60 * 1000 } = {}) { + const outputPath = tempOutputPath(); + + return new Promise((resolve, reject) => { + let settled = false; + + const command = ffmpeg(inputUrl) + .outputOptions([ + "-map 0:v:0", + "-map 0:a:0?", + "-c:v copy", + "-c:a copy", + "-movflags +faststart", + "-max_muxing_queue_size 9999", + ]) + .format("mp4"); + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + command.kill("SIGKILL"); + fs.promises.unlink(outputPath).catch(() => {}); + reject(new Error(`Remux timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + command + .on("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fs.promises.unlink(outputPath).catch(() => {}); + reject(err); + }) + .on("end", () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(outputPath); + }) + .save(outputPath); + }); +} + +module.exports = { needsRemux, remuxToFaststartMp4 }; diff --git a/services/s3.service.js b/services/s3.service.js index a071c91..6bd8c84 100644 --- a/services/s3.service.js +++ b/services/s3.service.js @@ -184,6 +184,38 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "image", }; } +// ─── uploadStream ───────────────────────────────────────────────────────────── +// +// Same as uploadFile(), but Body is a Node Readable stream instead of a +// Buffer — used by assetTranscode.service.js to push a remuxed video back to +// storage straight off local disk, without ever holding the whole (possibly +// multi-GB) file in this process's memory. Upload (lib-storage) auto-chunks +// a stream body into multipart the same way it does a large Buffer. +// +// input: { stream, originalname, mimetype, ownerType? } +// output: { url, uuid } ← uuid = the S3 key, stored as storage_key in DB +// +async function uploadStream({ stream, originalname, mimetype, ownerType = "video" }) { + if (!stream) { + throw Object.assign(new Error("A readable stream is required for S3 uploads."), { status: 400 }); + } + + const bucket = DEFAULT_BUCKET; + const key = buildKey(originalname, ownerType); + + const uploader = new Upload({ + client: s3, + params: { Bucket: bucket, Key: key, Body: stream, ContentType: mimetype }, + }); + + await uploader.done(); + + return { + url: await buildPublicUrl(key, bucket), + uuid: key, + }; +} + // ─── deleteFile ─────────────────────────────────────────────────────────────── // // Matches chibisafe.service.js interface. @@ -412,7 +444,7 @@ async function getFileMetadata(key) { } module.exports = { - uploadFile, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream, + uploadFile, uploadStream, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream, presignUpload, completeMultipartUpload, abortMultipartUpload, getFileMetadata, buildPublicUrl, ping, }; \ No newline at end of file diff --git a/utils/audienceResolver.util.js b/utils/audienceResolver.util.js index 5acba3c..f2820be 100644 --- a/utils/audienceResolver.util.js +++ b/utils/audienceResolver.util.js @@ -64,7 +64,7 @@ async function resolveCourseUserIds(courseUuid) { holders.forEach((h) => userIds.add(String(h.user_id))); } - const product = await mdl_Product.findOne({ where: { course_id: course.course_id } }); + const product = await mdl_Product.findOne({ where: { purchasable_type: 'course', purchasable_id: course.course_id } }); if (product) { const purchasers = await mdl_CoursePurchase.findAll({ attributes: ['user_id'], diff --git a/utils/purchasable.util.js b/utils/purchasable.util.js new file mode 100644 index 0000000..6421e05 --- /dev/null +++ b/utils/purchasable.util.js @@ -0,0 +1,45 @@ +/*********************************************************************************************************************************************************************** + * File Name: purchasable.util.js + * Type of Program: Utility + * Description: Resolves a Product's polymorphic target (purchasable_type + + * purchasable_id → Course | Unit | Lesson) and the checkout + * route for it. Products.mdl.js can't express a single + * Sequelize association across three different target models, + * so this is the shared lookup every consumer (access checks, + * admin product CRUD, client checkout) goes through instead. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Aug. 1, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const { Course } = require('../models/courses/courses.mdl'); +const Unit = require('../models/courses/units.mdl'); +const Lesson = require('../models/courses/lessons.mdl'); + +const TYPE_MODELS = { course: Course, unit: Unit, lesson: Lesson }; +const TYPE_PK = { course: 'course_id', unit: 'unit_id', lesson: 'lesson_id' }; + +function getPurchasableModel(type) { + return TYPE_MODELS[type] ?? null; +} + +// Returns the target row (needs at least uuid/course_id + subscription for +// callers), or null if the type is unknown or the row doesn't exist. +async function resolvePurchasable(type, id) { + const Model = TYPE_MODELS[type]; + if (!Model) return null; + return Model.findOne({ where: { [TYPE_PK[type]]: id, deletedAt: null } }); +} + +// Course checkout stays keyed by numeric course_id (existing convention); +// Unit/Lesson checkout is keyed by uuid, matching how /units/:uuid and +// /lessons/:uuid already work everywhere else in the standalone consumption paths. +function checkoutPath(type, record) { + if (type === 'course') return `/course/${record.course_id}/checkout`; + if (type === 'unit') return `/units/${record.uuid}/checkout`; + if (type === 'lesson') return `/lessons/${record.uuid}/checkout`; + return null; +} + +module.exports = { getPurchasableModel, resolvePurchasable, checkoutPath }; diff --git a/utils/resolveAvatar.util.js b/utils/resolveAvatar.util.js new file mode 100644 index 0000000..b7ba111 --- /dev/null +++ b/utils/resolveAvatar.util.js @@ -0,0 +1,48 @@ +// utils/resolveAvatar.util.js +// +// Resolves a stored avatar into a browser-usable URL 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. +// - 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. +// +const { getPublicUrl } = require('../services/s3.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 + } +} + +// Mutates-and-returns a shallow copy of a user (plain object or Sequelize +// instance) with personal_info.avatar resolved. Safe to call on a user with +// no avatar at all. +async function resolveUserAvatar(user) { + if (!user) return user; + const u = user.toJSON ? user.toJSON() : { ...user }; + if (!u.personal_info?.avatar) return u; + + u.personal_info = { + ...u.personal_info, + avatar: await resolveAvatarUrl(u.personal_info.avatar), + }; + return u; +} + +module.exports = { resolveAvatarUrl, resolveUserAvatar };