assets and tier plans revamp

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-01 17:44:25 +08:00
parent 39c3c4566b
commit cae958b5d5
41 changed files with 1271 additions and 165 deletions
+15
View File
@@ -8,6 +8,8 @@ const s3 = require("../../services/s3.service");
const mediaToken = require("../../services/mediaToken.service"); const mediaToken = require("../../services/mediaToken.service");
const uploadProgress = require("../../services/uploadProgress.service"); const uploadProgress = require("../../services/uploadProgress.service");
const { extractVideoMeta } = require("../../services/ffprobe.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 documentConversion = require("../../services/documentConversion.service");
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util"); const { paginate } = require("../../utils/paginate.util");
@@ -408,6 +410,11 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or
// ── DB insert ────────────────────────────────────────────────────────────── // ── 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(); const t = await sequelize.transaction();
try { try {
const asset = await Asset.create({ const asset = await Asset.create({
@@ -434,10 +441,18 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or
storage_key, storage_key,
is_public, is_public,
createdBy, createdBy,
transcode_status: needsTranscode ? "pending" : "none",
}, { transaction: t }); }, { transaction: t });
await t.commit(); 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 } }); 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; return asset;
} catch (dbErr) { } catch (dbErr) {
@@ -20,6 +20,7 @@ const CourseReadingProgress = require('../../models/courses/course_reading_progr
const { Course, Unit, Lesson, CourseUnit } = require('../../models/courses/courses.associations'); const { Course, Unit, Lesson, CourseUnit } = require('../../models/courses/courses.associations');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util'); const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util');
const notDeleted = { deletedAt: null }; const notDeleted = { deletedAt: null };
@@ -91,21 +92,22 @@ exports.getCourseReadingProgress = async (req, res) => {
if (row.type === 'lesson' && row.status === 'completed') entry.lessons_completed++; 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 u = userMap[entry.user_id];
const avatar = await resolveAvatarUrl(u?.personal_info?.avatar);
return { return {
...entry, ...entry,
user: { user: {
email: u?.email ?? null, email: u?.email ?? null,
full_name: u?.personal_info?.name?.full_name ?? null, full_name: u?.personal_info?.name?.full_name ?? null,
avatar_url: u?.personal_info?.avatar?.url ?? null, avatar_url: avatar?.url ?? null,
}, },
units_total, units_total,
lessons_total, lessons_total,
// Fall back to in_progress if the course row hasn't been written yet // Fall back to in_progress if the course row hasn't been written yet
course_status: entry.course_status ?? 'in_progress', course_status: entry.course_status ?? 'in_progress',
}; };
}); }));
// Sort: completed last, most recent first within each group // Sort: completed last, most recent first within each group
result.sort((a, b) => { result.sort((a, b) => {
+11 -9
View File
@@ -5,6 +5,7 @@ const sequelize = require("../../config/db.config");
const R = require("../../utils/response.util"); const R = require("../../utils/response.util");
const { paginate } = require("../../utils/paginate.util"); const { paginate } = require("../../utils/paginate.util");
const { recomputeDurations, recomputeUnitDuration, recomputeCourseDuration, formatDuration } = require("../../utils/duration.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 { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util");
const { resolvePrerequisiteTitles } = require("../../utils/courses/resolvePrerequisiteTitles.util"); const { resolvePrerequisiteTitles } = require("../../utils/courses/resolvePrerequisiteTitles.util");
const { syncJunction } = require("../../utils/courses/junction.util"); const { syncJunction } = require("../../utils/courses/junction.util");
@@ -2653,22 +2654,23 @@ exports.syncInstructors = async (req, res) => {
// ─── COMPLETIONS HELPERS ────────────────────────────────────────────────────── // ─── COMPLETIONS HELPERS ──────────────────────────────────────────────────────
function extractUserInfo(user) { async function extractUserInfo(user) {
if (!user) return { full_name: null, email: null, avatar_url: null, deleted: false }; if (!user) return { full_name: null, email: null, avatar_url: null, deleted: false };
const avatar = await resolveAvatarUrl(user.personal_info?.avatar);
return { return {
full_name: user.personal_info?.name?.full_name ?? null, full_name: user.personal_info?.name?.full_name ?? null,
email: user.email ?? null, email: user.email ?? null,
avatar_url: user.personal_info?.avatar?.url ?? null, avatar_url: avatar?.url ?? null,
deleted: !!user.deletedAt, deleted: !!user.deletedAt,
}; };
} }
function groupByUser(attempts) { async function groupByUser(attempts) {
const map = new Map(); const map = new Map();
for (const a of attempts) { for (const a of attempts) {
const uid = String(a.user_id); const uid = String(a.user_id);
if (!map.has(uid)) { if (!map.has(uid)) {
const { full_name, email, avatar_url, deleted } = extractUserInfo(a.user); const { full_name, email, avatar_url, deleted } = await extractUserInfo(a.user);
map.set(uid, { map.set(uid, {
user_id: a.user_id, user_id: a.user_id,
full_name, full_name,
@@ -2749,7 +2751,7 @@ exports.getQuizCompletions = async (req, res) => {
const plain = attempts.map((a) => a.toJSON()); const plain = attempts.map((a) => a.toJSON());
return R.success(res, "Quiz completions retrieved.", { return R.success(res, "Quiz completions retrieved.", {
summary: buildSummary(plain), summary: buildSummary(plain),
completions: groupByUser(plain), completions: await groupByUser(plain),
}); });
} catch (err) { } catch (err) {
console.error("[ADMIN][QUIZ][COMPLETIONS]", err); console.error("[ADMIN][QUIZ][COMPLETIONS]", err);
@@ -2773,7 +2775,7 @@ exports.getAssessmentCompletions = async (req, res) => {
const plain = attempts.map((a) => a.toJSON()); const plain = attempts.map((a) => a.toJSON());
return R.success(res, "Assessment completions retrieved.", { return R.success(res, "Assessment completions retrieved.", {
summary: buildSummary(plain), summary: buildSummary(plain),
completions: groupByUser(plain), completions: await groupByUser(plain),
}); });
} catch (err) { } catch (err) {
console.error("[ADMIN][ASSESSMENT][COMPLETIONS]", err); console.error("[ADMIN][ASSESSMENT][COMPLETIONS]", err);
@@ -2794,9 +2796,9 @@ exports.getAssessmentSessions = async (req, res) => {
order: [["createdAt", "DESC"]], order: [["createdAt", "DESC"]],
}); });
const rows = sessions.map((s) => { const rows = await Promise.all(sessions.map(async (s) => {
const j = s.toJSON(); const j = s.toJSON();
const { full_name, email, avatar_url, deleted } = 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 const time_spent_seconds = j.status !== 'in_progress' && j.started_at
? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000) ? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000)
: null; : null;
@@ -2813,7 +2815,7 @@ exports.getAssessmentSessions = async (req, res) => {
time_spent_seconds, time_spent_seconds,
attempt_id: j.attempt_id, attempt_id: j.attempt_id,
}; };
}); }));
const summary = { const summary = {
total_sessions: rows.length, total_sessions: rows.length,
+45 -2
View File
@@ -37,6 +37,7 @@ const {
} = require("../../models/courses/courses.associations"); } = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl"); const mdl_Users = require("../../models/users/users.mdl");
const { mdl_PlanLessons, mdl_TierPlans } = require("../../models/tiers/tier.associations");
const notDeleted = { deletedAt: null }; const notDeleted = { deletedAt: null };
const onlyDeleted = { deletedAt: { [Op.not]: 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) => { exports.getLesson = async (req, res) => {
try { try {
const { lessonId } = req.params; const { lessonId } = req.params;
@@ -179,11 +220,12 @@ exports.getLesson = async (req, res) => {
exports.createLesson = async (req, res) => { exports.createLesson = async (req, res) => {
const t = await sequelize.transaction(); const t = await sequelize.transaction();
try { 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); if (!title) return R.error(res, "Title is required.", 400);
const lesson = await Lesson.create({ const lesson = await Lesson.create({
title, title,
subscription: subscription || null,
description: description ?? null, description: description ?? null,
duration_seconds: 0, duration_seconds: 0,
createdBy: createdBy ?? req.user?.user_id ?? null, 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 } }); const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } });
if (!lesson) return R.error(res, "Lesson not found.", 404); 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 (title !== undefined) lesson.title = title;
if (description !== undefined) lesson.description = description; if (description !== undefined) lesson.description = description;
if (subscription !== undefined) lesson.subscription = subscription || null;
lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null; lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null;
await lesson.save({ transaction: t }); await lesson.save({ transaction: t });
+71 -31
View File
@@ -5,54 +5,94 @@ const { Course, CourseProductCategory: mdl_CourseProductCategory } = require('..
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.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) => { async function getProductFor(purchasable_type, purchasable_id) {
try { return mdl_Product.findOne({ where: { purchasable_type, purchasable_id }, paranoid: false });
const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId }, paranoid: false }); }
return R.success(res, 'Product retrieved.', product ?? null);
} catch (err) { async function upsertProductFor(purchasable_type, purchasable_id, body, adminUserId) {
console.error('[ADMIN][PRODUCTS][GET]', err); const { name, description, price, currency, access_days, is_active } = body;
return R.error(res, 'Could not retrieve product.', 500); if (!name || price == null) {
const err = new Error('name and price are required.');
err.status = 400;
throw err;
} }
};
exports.upsertCourseProduct = async (req, res) => { const existing = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id }, paranoid: false });
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: { course_id: courseId }, paranoid: false });
if (existing) { if (existing) {
if (existing.deletedAt) await existing.restore(); 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 }); 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 } }); logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: existing.id, details: { purchasable_type, purchasable_id, name } });
return R.success(res, 'Product updated.', existing); return { product: existing, created: false };
} }
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 }); 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(req.user?.user_id, 'upsert_course_product', { entityType: 'product', entityId: product.product_id, details: { course_id: courseId, name } }); logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: product.id, details: { purchasable_type, purchasable_id, name } });
return R.success(res, 'Product created.', product, 201); 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) { } catch (err) {
console.error('[ADMIN][PRODUCTS][UPSERT]', 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); return R.error(res, 'Could not save product.', 500);
} }
}; },
remove: async (req, res) => {
exports.removeCourseProduct = async (req, res) => {
try { try {
const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId } }); const removed = await removeProductFor(purchasable_type, req.params[paramName], req.user?.user_id);
if (!product) return R.error(res, 'Product not found.', 404); if (!removed) 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.'); return R.success(res, 'Product removed.');
} catch (err) { } catch (err) {
console.error('[ADMIN][PRODUCTS][REMOVE]', err); console.error(`[ADMIN][PRODUCTS][REMOVE][${purchasable_type}]`, err);
return R.error(res, 'Could not remove product.', 500); 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) ────────────────────────────────────────────────── // ─── CATEGORIES (per course) ──────────────────────────────────────────────────
+4 -3
View File
@@ -18,6 +18,7 @@
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { uploadFile, deleteFile } = require('../../services/s3.service'); const { uploadFile, deleteFile } = require('../../services/s3.service');
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
// ─── GET own profile ─────────────────────────────────────────────────────────── // ─── GET own profile ───────────────────────────────────────────────────────────
@@ -26,7 +27,7 @@ exports.getProfile = async (req, res) => {
const user = await mdl_Users.findByPk(req.user.user_id, { const user = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, 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) { } catch (err) {
return R.error(res, 'Could not retrieve profile.', 500); 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, { const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, 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) { } catch (err) {
console.error('[ADMIN] updateProfile error:', err); console.error('[ADMIN] updateProfile error:', err);
return R.error(res, 'Profile update failed.', 500); 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, { const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, 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) { } catch (err) {
console.error('[ADMIN] uploadAvatar error:', err); console.error('[ADMIN] uploadAvatar error:', err);
return R.error(res, 'Avatar upload failed.', 500); return R.error(res, 'Avatar upload failed.', 500);
+108
View File
@@ -16,7 +16,11 @@ const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const mdl_PlanCourses = require('../../models/tiers/plan_courses.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 { 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'); require('../../models/tiers/tier.associations');
@@ -546,3 +550,107 @@ exports.syncPlanCourses = async (req, res) => {
return R.error(res, 'Could not update plan courses.', 500); 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);
}
};
+41
View File
@@ -41,6 +41,7 @@ const CompletionRequirement = require("../../models/courses/completion_requireme
const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry"); const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry");
const mdl_Users = require("../../models/users/users.mdl"); const mdl_Users = require("../../models/users/users.mdl");
const { mdl_PlanUnits, mdl_TierPlans } = require("../../models/tiers/tier.associations");
const notDeleted = { deletedAt: null }; const notDeleted = { deletedAt: null };
const onlyDeleted = { deletedAt: { [Op.not]: 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) => { exports.getUnit = async (req, res) => {
try { try {
const { unitId } = req.params; const { unitId } = req.params;
@@ -13,6 +13,7 @@ const { Op } = require('sequelize');
const mdl_UserActivity = require('../../models/users/user_activity.mdl'); const mdl_UserActivity = require('../../models/users/user_activity.mdl');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util');
const USER_ATTRS = [ const USER_ATTRS = [
'user_id', 'email', 'acc_type', 'user_id', 'email', 'acc_type',
@@ -66,7 +67,7 @@ exports.getActivity = async (req, res) => {
offset, offset,
}); });
const data = rows.map(formatRow); const data = await Promise.all(rows.map(formatRow));
return R.success(res, 'Activity feed retrieved.', { return R.success(res, 'Activity feed retrieved.', {
total: count, total: count,
@@ -117,15 +118,16 @@ exports.getUserActivity = async (req, res) => {
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatRow(row) { async function formatRow(row) {
const r = row.toJSON(); const r = row.toJSON();
const info = r.user?.personal_info; const info = r.user?.personal_info;
const avatar = await resolveAvatarUrl(info?.avatar);
return { return {
activity_id: r.activity_id, activity_id: r.activity_id,
user_id: r.user_id, user_id: r.user_id,
email: r.user?.email ?? null, email: r.user?.email ?? null,
full_name: info?.name?.full_name ?? null, full_name: info?.name?.full_name ?? null,
avatar_url: info?.avatar?.url ?? null, avatar_url: avatar?.url ?? null,
acc_type: r.user?.acc_type ?? null, acc_type: r.user?.acc_type ?? null,
action: r.action, action: r.action,
entity_type: r.entity_type, entity_type: r.entity_type,
+7 -6
View File
@@ -51,16 +51,17 @@ const { sendEmail } = require('../services/email.service');
const buildSessionInfo = require('../utils/session_info.util'); const buildSessionInfo = require('../utils/session_info.util');
const logActivity = require('../utils/logActivity.util'); const logActivity = require('../utils/logActivity.util');
const trustedDevice = require('../services/trustedDevice.service'); const trustedDevice = require('../services/trustedDevice.service');
const { resolveUserAvatar } = require('../utils/resolveAvatar.util');
const R = require('../utils/response.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 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 }; const u = user.toJSON ? user.toJSON() : { ...user };
[...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]); [...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]);
return u; return resolveUserAvatar(u);
}; };
const setRefreshCookie = (res, refreshToken) => { 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.', { return R.success(res, wasVerified ? 'Login successful.' : 'Email verified successfully. You are now logged in.', {
accessToken, accessToken,
session_id: session.session_id, session_id: session.session_id,
user: safeUser(user), user: await safeUser(user),
}); });
} catch (err) { } catch (err) {
await transaction.rollback(); await transaction.rollback();
@@ -403,7 +404,7 @@ exports.login = async (req, res) => {
otpRequired: false, otpRequired: false,
accessToken, accessToken,
session_id: session.session_id, 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 // Check if refresh token is expired
if (!shouldRotateRefreshToken(decoded)) { if (!shouldRotateRefreshToken(decoded)) {
const { accessToken } = generateTokens(user); 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 ─────────────────────────────────────────────────── // ─── Rotate refresh token ───────────────────────────────────────────────────
@@ -646,7 +647,7 @@ exports.refreshToken = async (req, res) => {
setRefreshCookie(res, tokens.refreshToken); 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) { } catch (err) {
console.error('[AUTH] refresh token error:', err); console.error('[AUTH] refresh token error:', err);
return R.error(res, 'Invalid or expired refresh token.', 401); return R.error(res, 'Invalid or expired refresh token.', 401);
@@ -3,8 +3,12 @@ const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const mdl_Product = require('../../models/courses/products.mdl'); const mdl_Product = require('../../models/courses/products.mdl');
const paymentSvc = require('../../services/payment.service'); const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util');
// ─── CREATE ORDER ───────────────────────────────────────────────────────────── // ─── 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) => { exports.createCourseOrder = async (req, res) => {
try { try {
@@ -19,15 +23,19 @@ exports.createCourseOrder = async (req, res) => {
}); });
if (existing) { if (existing) {
const stillActive = !existing.expires_at || new Date(existing.expires_at) > new Date(); 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', { const ppOrder = await paymentSvc.createOrder('paypal', {
amount: Number(product.price).toFixed(2), amount: Number(product.price).toFixed(2),
currency: product.currency, currency: product.currency,
referenceId: `user_${req.user.user_id}_product_${product_id}`, referenceId: `user_${req.user.user_id}_product_${product_id}`,
returnUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout`, returnUrl: `${process.env.FRONTEND_URL}${path}`,
cancelUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout?cancelled=true`, cancelUrl: `${process.env.FRONTEND_URL}${path}?cancelled=true`,
}); });
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null; 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.', { const target = await resolvePurchasable(purchase.product.purchasable_type, purchase.product.purchasable_id);
return R.success(res, 'Payment successful. Access granted.', {
purchase_id: purchase.id, purchase_id: purchase.id,
expires_at: purchase.expires_at, expires_at: purchase.expires_at,
course_id: purchase.product.course_id, purchasable_type: purchase.product.purchasable_type,
purchasable_id: purchase.product.purchasable_id,
target_uuid: target?.uuid ?? null,
}); });
} catch (err) { } catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err); console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err);
@@ -144,7 +156,7 @@ exports.getMyPurchases = async (req, res) => {
try { try {
const purchases = await mdl_CoursePurchase.findAll({ const purchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id }, 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'] }, attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
}); });
+117 -22
View File
@@ -127,6 +127,23 @@ async function buildUserContext(user_id) {
return { tier, tierRankMap, rulesets, group_ids, activeTiers }; 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 ───────────────────────────────────── // ─── Shared tier + purchase access check ─────────────────────────────────────
// Returns true → user may access the course. // Returns true → user may access the course.
// Returns false → user's tier is too low AND no valid individual purchase. // 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 // Individual purchase as fallback
const product = await mdl_Product.findOne({ where: { course_id } }); return hasActivePurchase(user_id, 'course', 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;
} }
// ─── Standalone access checks (junction revamp) ────────────────────────────── // ─── Standalone access checks (junction revamp) ──────────────────────────────
// A Unit attached to no course is open to every authenticated user; a Unit // 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. // 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 // Lessons resolve the same way through their parent units. Both Unit and
// content locked while letting genuinely standalone content run independently. // 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) { async function canAccessUnit(user_id, unit_id) {
// A unit's own subscription (standalone tier-gating) is an additional, // 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 (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 // Only links to PUBLISHED courses count as a real course dependency — a unit
// whose only link is to a draft/unpublished course behaves as if it had no // whose only link is to a draft/unpublished course behaves as if it had no
// course link at all (falls through to the free/standalone branch below), // course link at all (falls through to the free/standalone branch below),
@@ -212,8 +222,22 @@ async function canAccessUnit(user_id, unit_id) {
} }
async function canAccessLesson(user_id, lesson_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'] }); 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) { for (const link of unitLinks) {
if (await canAccessUnit(user_id, link.unit_id)) return true; 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 userCtx = await buildUserContext(req.user.user_id);
const userTier = userCtx.tier; 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({ const myPurchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id, status: 'completed' }, 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'], attributes: ['id', 'expires_at', 'product_id'],
}); });
const purchasedCourseIds = new Set( const purchasedCourseIds = new Set(
myPurchases myPurchases
.filter((p) => !p.expires_at || new Date(p.expires_at) > new Date()) .filter((p) => p.product?.purchasable_type === 'course' && (!p.expires_at || new Date(p.expires_at) > new Date()))
.map((p) => String(p.product?.course_id)) .map((p) => String(p.product.purchasable_id))
); );
// Build category filter // Build category filter
@@ -506,7 +532,7 @@ exports.getCourse = async (req, res) => {
// Attach product info and purchase status for the buy-course flow // Attach product info and purchase status for the buy-course flow
const product = await mdl_Product.findOne({ 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'], attributes: ['id', 'name', 'price', 'currency', 'access_days'],
}); });
const hasPurchase = product && await mdl_CoursePurchase.findOne({ const hasPurchase = product && await mdl_CoursePurchase.findOne({
@@ -1322,7 +1348,7 @@ exports.getLessonsByUnitUuid = async (req, res) => {
const { uuid } = req.params; const { uuid } = req.params;
const unit = await Unit.findOne({ const unit = await Unit.findOne({
where: { uuid, ...notDeleted }, where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description", "duration_seconds"], attributes: ["unit_id", "uuid", "title", "subscription", "description", "duration_seconds"],
include: [ include: [
{ {
model: Course, as: "courses", model: Course, as: "courses",
@@ -1353,10 +1379,12 @@ exports.getLessonsByUnitUuid = async (req, res) => {
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) { if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
const first = unit.courses?.[0] ?? null; const first = unit.courses?.[0] ?? null;
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
return res.status(403).json({ return res.status(403).json({
status: "error", status: "error",
message: "You do not have access to this unit.", message: "You do not have access to this unit.",
course: first ? { title: first.title, subscription: first.subscription } : null, course: first ? { title: first.title, subscription: first.subscription } : null,
item: { uuid: unit.uuid, subscription: unit.subscription, product, has_purchased },
}); });
} }
@@ -1446,7 +1474,7 @@ exports.getLessonByUuid = async (req, res) => {
const { uuid } = req.params; const { uuid } = req.params;
const lesson = await Lesson.findOne({ const lesson = await Lesson.findOne({
where: { uuid, ...notDeleted }, where: { uuid, ...notDeleted },
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"], attributes: ["lesson_id", "uuid", "title", "subscription", "description", "duration_seconds"],
include: [ include: [
{ {
model: LessonPage, model: LessonPage,
@@ -1480,10 +1508,12 @@ exports.getLessonByUuid = async (req, res) => {
if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) { if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) {
const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null; const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null;
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
return res.status(403).json({ return res.status(403).json({
status: "error", status: "error",
message: "You do not have access to this lesson.", message: "You do not have access to this lesson.",
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null, course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
item: { uuid: lesson.uuid, subscription: lesson.subscription, product, has_purchased },
}); });
} }
@@ -1528,3 +1558,68 @@ exports.getLessonByUuid = async (req, res) => {
return R.error(res, "Could not retrieve lesson.", 500); 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);
}
};
+4 -3
View File
@@ -21,6 +21,7 @@ const trustedDevice = require('../../services/trustedDevice.service');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { uploadFile, deleteFile } = require('../../services/s3.service'); const { uploadFile, deleteFile } = require('../../services/s3.service');
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
// ─── GET own profile ─────────────────────────────────────────────────────────── // ─── GET own profile ───────────────────────────────────────────────────────────
@@ -29,7 +30,7 @@ exports.getProfile = async (req, res) => {
const user = await mdl_Users.findByPk(req.user.user_id, { const user = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, 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) { } catch (err) {
return R.error(res, 'Could not retrieve profile.', 500); 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'); 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) { } catch (err) {
console.error('[CLIENT] updateProfile error:', err); console.error('[CLIENT] updateProfile error:', err);
return R.error(res, 'Profile update failed.', 500); 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, { const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, 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) { } catch (err) {
console.error('[CLIENT] uploadAvatar error:', err); console.error('[CLIENT] uploadAvatar error:', err);
return R.error(res, 'Avatar upload failed.', 500); return R.error(res, 'Avatar upload failed.', 500);
+33 -30
View File
@@ -184,12 +184,13 @@ exports.createOrder = async (req, res) => {
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404); 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({ const existingActive = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, tier: plan.tier, status: 'active' }, where: { user_id: req.user.user_id, 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 effectivePrice = Number(plan.price);
const effectiveCurrency = plan.currency; const effectiveCurrency = plan.currency;
@@ -249,6 +250,8 @@ exports.createOrder = async (req, res) => {
currency: effectiveCurrency, currency: effectiveCurrency,
promo_code: promoResult.code, promo_code: promoResult.code,
discount: discount.toFixed(2), discount: discount.toFixed(2),
extends_existing: !!existingActive,
current_expires_at: existingActive?.expires_at ?? null,
}, 201); }, 201);
} catch (err) { } catch (err) {
console.error('[CLIENT][CREATE ORDER]', err); console.error('[CLIENT][CREATE ORDER]', err);
@@ -297,35 +300,30 @@ exports.captureOrder = async (req, res) => {
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0]; const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// Scoped, defensive re-check: createOrder already blocked this, but time may // Repurchasing a plan under a tier already held active extends the
// have passed (or two checkout tabs raced) between order creation and capture. // existing grant's expires_at by the new plan's duration, rather than
// Money has already moved via PayPal at this point, so auto-refund rather than // being blocked/refunded — the original plan_id is kept (whichever plan
// leaving the user charged with nothing to show for it. // 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({ const existingActive = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, tier: payment.plan.tier, status: 'active' }, where: { user_id: req.user.user_id, tier: payment.plan.tier, status: 'active' },
}); });
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);
}
}
let resultTier;
let successMessage;
if (existingActive) {
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 startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000); const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({ resultTier = await mdl_UserTiers.create({
user_id: req.user.user_id, user_id: req.user.user_id,
tier: payment.plan.tier, tier: payment.plan.tier,
plan_id: payment.plan_id, plan_id: payment.plan_id,
@@ -334,10 +332,12 @@ exports.captureOrder = async (req, res) => {
expires_at: expiresAt, expires_at: expiresAt,
granted_by: null, granted_by: null,
}); });
successMessage = 'Payment successful. Tier activated.';
}
await payment.update({ await payment.update({
status: 'completed', status: 'completed',
tier_id: newTier.tier_id, tier_id: resultTier.tier_id,
paid_at: new Date(), paid_at: new Date(),
provider_payload: { provider_payload: {
...payment.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.', { return R.success(res, successMessage, {
tier: newTier.tier, tier: resultTier.tier,
expires_at: newTier.expires_at, expires_at: resultTier.expires_at,
extended: !!existingActive,
}); });
} catch (err) { } catch (err) {
console.error('[CLIENT][CAPTURE ORDER]', err); console.error('[CLIENT][CAPTURE ORDER]', err);
+57 -7
View File
@@ -30,9 +30,12 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
"use strict"; "use strict";
const { Op } = require("sequelize");
const R = require("../../utils/response.util"); const R = require("../../utils/response.util");
const logActivity = require("../../utils/logActivity.util"); const logActivity = require("../../utils/logActivity.util");
const sequelize = require("../../config/db.config"); const sequelize = require("../../config/db.config");
const mdl_Product = require("../../models/courses/products.mdl");
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const { const {
Unit, Lesson, Unit, Lesson,
@@ -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) ────────────────────────────────────────────── // ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
// Client-side Units/Lessons browsing shows ALL content, bound to a course or // Client-side Units/Lessons browsing shows ALL content, bound to a course or
@@ -104,6 +136,8 @@ exports.getUnits = async (req, res) => {
coursesByUnit.set(row.unit_id, list); coursesByUnit.set(row.unit_id, list);
} }
const { productById, purchasedIds } = await attachProducts(req.user.user_id, "unit", unitIds);
// is_locked mirrors canAccessUnit: a unit with its own subscription or at // is_locked mirrors canAccessUnit: a unit with its own subscription or at
// least one attached course needs an access check; a fully open standalone // least one attached course needs an access check; a fully open standalone
// unit (no subscription, no course links) is never locked. // unit (no subscription, no course links) is never locked.
@@ -112,7 +146,13 @@ exports.getUnits = async (req, res) => {
const is_locked = (row.subscription || Number(row.course_count) > 0) const is_locked = (row.subscription || Number(row.course_count) > 0)
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id)) ? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
: false; : false;
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); return R.success(res, "Units retrieved.", result);
@@ -131,7 +171,7 @@ exports.getLessons = async (req, res) => {
try { try {
const rows = await sequelize.query(` const rows = await sequelize.query(`
SELECT 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 (SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL 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 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); coursesByLesson.set(row.lesson_id, list);
} }
// is_locked mirrors canAccessLesson: standalone/unattached lessons are const { productById, purchasedIds } = await attachProducts(req.user.user_id, "lesson", lessonIds);
// open, attached lessons need at least one accessible course through
// any attached unit. // 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 = []; const result = [];
for (const row of rows) { 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)) ? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
: false; : 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); return R.success(res, "Lessons retrieved.", result);
@@ -481,3 +529,5 @@ exports.markStandaloneLessonComplete = async (req, res) => {
exports.getUnitByUuid = coursesCtrl.getUnitByUuid; exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid; exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
exports.getLessonByUuid = coursesCtrl.getLessonByUuid; exports.getLessonByUuid = coursesCtrl.getLessonByUuid;
exports.getUnitCheckoutInfo = coursesCtrl.getUnitCheckoutInfo;
exports.getLessonCheckoutInfo = coursesCtrl.getLessonCheckoutInfo;
+3
View File
@@ -23,6 +23,7 @@
* Currently registered: * Currently registered:
* - taskOverdue (cron/jobs/task_overdue.cron.js) — settings-backed * - taskOverdue (cron/jobs/task_overdue.cron.js) — settings-backed
* - liftExpiredBans (cron/jobs/lift_expired_bans.cron.js) * - liftExpiredBans (cron/jobs/lift_expired_bans.cron.js)
* - retryStuckTranscodes (cron/jobs/retry_stuck_transcodes.cron.js)
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026 * Date Created: Jun. 17, 2026
@@ -30,6 +31,7 @@
const cron = require('node-cron'); const cron = require('node-cron');
const taskOverdue = require('./jobs/task_overdue.cron'); const taskOverdue = require('./jobs/task_overdue.cron');
const liftExpiredBans = require('./jobs/lift_expired_bans.cron'); const liftExpiredBans = require('./jobs/lift_expired_bans.cron');
const retryStuckTranscodes = require('./jobs/retry_stuck_transcodes.cron');
const { startSettingsBackedJobs } = require('./cronRegistry.util'); const { startSettingsBackedJobs } = require('./cronRegistry.util');
// ─── Registry — add future admin-side cron jobs here ───────────────────────── // ─── Registry — add future admin-side cron jobs here ─────────────────────────
@@ -40,6 +42,7 @@ const settingsBackedJobs = [
// Plain hardcoded-schedule jobs (not tied to any notification setting). // Plain hardcoded-schedule jobs (not tied to any notification setting).
const plainJobs = [ const plainJobs = [
liftExpiredBans, liftExpiredBans,
retryStuckTranscodes,
]; ];
// ─── Boot all registered admin-side jobs ────────────────────────────────────── // ─── Boot all registered admin-side jobs ──────────────────────────────────────
+65
View File
@@ -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,
};
@@ -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');
},
};
@@ -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');
},
};
@@ -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');
},
};
@@ -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');
},
};
@@ -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');
},
};
+7
View File
@@ -39,6 +39,13 @@ const Asset = sequelize.define("Asset", {
thumbnail_url: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true }, thumbnail_url: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true },
thumbnail_storage_key: { 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 ──────────────────────────────────────────────────────────
description: { type: DataTypes.TEXT, label: "Description", hidden: true }, description: { type: DataTypes.TEXT, label: "Description", hidden: true },
+1
View File
@@ -9,6 +9,7 @@ const Lesson = sequelize.define("Lesson", {
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1, filterable: true }, 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 // 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 }, 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 duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, filterable: false }, // computed from blocks on save
+20 -4
View File
@@ -2,10 +2,22 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config'); const sequelize = require('../../config/db.config');
const { Course } = require('./courses.mdl'); const { Course } = require('./courses.mdl');
const Unit = require('./units.mdl');
const Lesson = require('./lessons.mdl');
// Polymorphic target — a product is sold against exactly one Course, Unit, or
// 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', { const mdl_Product = sequelize.define('Product', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false }, purchasable_type: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'course' },
purchasable_id: { type: DataTypes.BIGINT, allowNull: false },
name: { type: DataTypes.STRING(200), allowNull: false }, name: { type: DataTypes.STRING(200), allowNull: false },
description: { type: DataTypes.TEXT, allowNull: true }, description: { type: DataTypes.TEXT, allowNull: true },
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false }, price: { type: DataTypes.DECIMAL(10, 2), allowNull: false },
@@ -18,8 +30,12 @@ const mdl_Product = sequelize.define('Product', {
paranoid: true, paranoid: true,
}); });
// A course has one product listing; a product belongs to one course // Scoped hasOne per target type — Sequelize automatically adds the matching
mdl_Product.belongsTo(Course, { foreignKey: 'course_id', as: 'course' }); // purchasable_type filter to the join, so existing
Course.hasOne(mdl_Product, { foreignKey: 'course_id', as: 'product' }); // `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; module.exports = mdl_Product;
+24
View File
@@ -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;
+24
View File
@@ -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;
+42
View File
@@ -4,10 +4,14 @@ const mdl_TierPlans = require('./tier_plans.mdl');
const mdl_UserTiers = require('./user_tiers.mdl'); const mdl_UserTiers = require('./user_tiers.mdl');
const mdl_Payments = require('./payments.mdl'); const mdl_Payments = require('./payments.mdl');
const mdl_PlanCourses = require('./plan_courses.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_PlanPolicies = require('./plan_policies.mdl');
const mdl_SystemBadges = require('../system_badges/system_badges.mdl'); const mdl_SystemBadges = require('../system_badges/system_badges.mdl');
const Asset = require('../assets/assets.mdl'); const Asset = require('../assets/assets.mdl');
const { Course } = require('../courses/courses.mdl'); const { Course } = require('../courses/courses.mdl');
const Unit = require('../courses/units.mdl');
const Lesson = require('../courses/lessons.mdl');
// ─── TierCategory ───────────────────────────────────────────────────────────── // ─── TierCategory ─────────────────────────────────────────────────────────────
mdl_TierCategories.belongsTo(Asset, { foreignKey: 'badge_asset_id', as: 'badgeAsset' }); 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' }); Course.hasOne(mdl_PlanCourses, { as: 'planCourse', foreignKey: 'course_id' });
mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_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 ────────────────────────────────────────────────────────── // ─── UserTier → Plan ──────────────────────────────────────────────────────────
mdl_UserTiers.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_UserTiers.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' }); mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' });
@@ -62,6 +102,8 @@ module.exports = {
mdl_UserTiers, mdl_UserTiers,
mdl_Payments, mdl_Payments,
mdl_PlanCourses, mdl_PlanCourses,
mdl_PlanUnits,
mdl_PlanLessons,
mdl_PlanPolicies, mdl_PlanPolicies,
mdl_SystemBadges, mdl_SystemBadges,
}; };
+1
View File
@@ -18,6 +18,7 @@ const requirementsCtrl = require("../../controllers/admin/completion_requirement
router.get("/", ctrl.getLessons); router.get("/", ctrl.getLessons);
router.post("/", ctrl.createLesson); router.post("/", ctrl.createLesson);
router.get("/flat", ctrl.getLessonsFlat); router.get("/flat", ctrl.getLessonsFlat);
router.get("/by-subscription", ctrl.getLessonsBySubscription);
router.get("/field-values", ctrl.getLessonFieldValues); router.get("/field-values", ctrl.getLessonFieldValues);
router.delete("/bulk", ctrl.bulkArchiveLessons); router.delete("/bulk", ctrl.bulkArchiveLessons);
router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteLessons); router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteLessons);
+10
View File
@@ -7,6 +7,16 @@ router.get ('/courses/:courseId/product', ctrl.getCourseProduct);
router.put ('/courses/:courseId/product', ctrl.upsertCourseProduct); router.put ('/courses/:courseId/product', ctrl.upsertCourseProduct);
router.delete('/courses/:courseId/product', ctrl.removeCourseProduct); router.delete('/courses/:courseId/product', ctrl.removeCourseProduct);
// Product per standalone unit
router.get ('/units/:unitId/product', ctrl.getUnitProduct);
router.put ('/units/:unitId/product', ctrl.upsertUnitProduct);
router.delete('/units/:unitId/product', ctrl.removeUnitProduct);
// Product per standalone lesson
router.get ('/lessons/:lessonId/product', ctrl.getLessonProduct);
router.put ('/lessons/:lessonId/product', ctrl.upsertLessonProduct);
router.delete('/lessons/:lessonId/product', ctrl.removeLessonProduct);
// Categories per course // Categories per course
router.get ('/courses/:courseId/categories', ctrl.getCourseCategories); router.get ('/courses/:courseId/categories', ctrl.getCourseCategories);
router.post ('/courses/:courseId/categories', ctrl.syncCourseCategories); router.post ('/courses/:courseId/categories', ctrl.syncCourseCategories);
+5 -1
View File
@@ -18,11 +18,15 @@ router.get ('/users/:id/tiers', ctrl.getUserTiers);
router.post ('/users/tiers/grant', ctrl.grantTier); router.post ('/users/tiers/grant', ctrl.grantTier);
router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier); 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/impact', ctrl.getPlanImpact);
router.get ('/:id/permanent-delete-impact', ctrl.getPlanPermanentDeleteImpact); router.get ('/:id/permanent-delete-impact', ctrl.getPlanPermanentDeleteImpact);
router.get ('/:id/courses', ctrl.getPlanCourses); router.get ('/:id/courses', ctrl.getPlanCourses);
router.post ('/:id/courses', ctrl.syncPlanCourses); 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.get ('/:id', ctrl.getPlan);
router.put ('/:id', ctrl.updatePlan); router.put ('/:id', ctrl.updatePlan);
+1
View File
@@ -20,6 +20,7 @@ router.get("/", ctrl.getUnits);
router.post("/", ctrl.createUnit); router.post("/", ctrl.createUnit);
router.post("/full", ctrl.createUnitFull); router.post("/full", ctrl.createUnitFull);
router.get("/flat", ctrl.getUnitsFlat); router.get("/flat", ctrl.getUnitsFlat);
router.get("/by-subscription", ctrl.getUnitsBySubscription);
router.get("/field-values", ctrl.getUnitFieldValues); router.get("/field-values", ctrl.getUnitFieldValues);
router.delete("/bulk", ctrl.bulkArchiveUnits); router.delete("/bulk", ctrl.bulkArchiveUnits);
router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteUnits); router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteUnits);
+1
View File
@@ -23,6 +23,7 @@ router.get('/quiz/uuid/:uuid', ctrl.getQuizByUuid);
// Courses // Courses
router.get('/', ctrl.getCourses); router.get('/', ctrl.getCourses);
router.get('/:courseId/checkout-info', ctrl.getCourseCheckoutInfo);
router.get('/:courseId', ctrl.getCourse); router.get('/:courseId', ctrl.getCourse);
// Unit // Unit
+1
View File
@@ -16,6 +16,7 @@ const router = express.Router();
const ctrl = require('../../controllers/client/units.controller'); const ctrl = require('../../controllers/client/units.controller');
router.get('/', ctrl.getLessons); router.get('/', ctrl.getLessons);
router.get('/:uuid/checkout-info', ctrl.getLessonCheckoutInfo);
router.get('/:uuid', ctrl.getLessonByUuid); router.get('/:uuid', ctrl.getLessonByUuid);
router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress); router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress);
router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress); router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress);
+1
View File
@@ -19,6 +19,7 @@ const ctrl = require('../../controllers/client/units.controller');
router.get('/', ctrl.getUnits); router.get('/', ctrl.getUnits);
router.get('/:uuid/lessons', ctrl.getLessonsByUnitUuid); router.get('/:uuid/lessons', ctrl.getLessonsByUnitUuid);
router.get('/:uuid/quiz', ctrl.getUnitQuiz); router.get('/:uuid/quiz', ctrl.getUnitQuiz);
router.get('/:uuid/checkout-info', ctrl.getUnitCheckoutInfo);
router.patch('/:uuid/quiz/:quizId/draft', ctrl.saveUnitQuizDraft); router.patch('/:uuid/quiz/:quizId/draft', ctrl.saveUnitQuizDraft);
router.post('/:uuid/quiz/:quizId/submit', ctrl.submitUnitQuiz); router.post('/:uuid/quiz/:quizId/submit', ctrl.submitUnitQuiz);
router.get('/:uuid', ctrl.getUnitByUuid); router.get('/:uuid', ctrl.getUnitByUuid);
+82
View File
@@ -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 };
+102
View File
@@ -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 };
+33 -1
View File
@@ -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 ─────────────────────────────────────────────────────────────── // ─── deleteFile ───────────────────────────────────────────────────────────────
// //
// Matches chibisafe.service.js interface. // Matches chibisafe.service.js interface.
@@ -412,7 +444,7 @@ async function getFileMetadata(key) {
} }
module.exports = { module.exports = {
uploadFile, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream, uploadFile, uploadStream, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream,
presignUpload, completeMultipartUpload, abortMultipartUpload, presignUpload, completeMultipartUpload, abortMultipartUpload,
getFileMetadata, buildPublicUrl, ping, getFileMetadata, buildPublicUrl, ping,
}; };
+1 -1
View File
@@ -64,7 +64,7 @@ async function resolveCourseUserIds(courseUuid) {
holders.forEach((h) => userIds.add(String(h.user_id))); 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) { if (product) {
const purchasers = await mdl_CoursePurchase.findAll({ const purchasers = await mdl_CoursePurchase.findAll({
attributes: ['user_id'], attributes: ['user_id'],
+45
View File
@@ -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 };
+48
View File
@@ -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 };