mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
assets and tier plans revamp
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -8,6 +8,8 @@ const s3 = require("../../services/s3.service");
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
const uploadProgress = require("../../services/uploadProgress.service");
|
||||
const { extractVideoMeta } = require("../../services/ffprobe.service");
|
||||
const ffmpegSvc = require("../../services/ffmpeg.service");
|
||||
const assetTranscode = require("../../services/assetTranscode.service");
|
||||
const documentConversion = require("../../services/documentConversion.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
@@ -408,6 +410,11 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or
|
||||
|
||||
// ── DB insert ──────────────────────────────────────────────────────────────
|
||||
|
||||
// .mov/.mkv videos load slowly in-browser (moov/Cues index at the end of
|
||||
// the file) — flag them for the background remux job (see
|
||||
// assetTranscode.service.js) fired below, right after commit.
|
||||
const needsTranscode = storage_provider === "s3" && file_type === "video" && ffmpegSvc.needsRemux(extension);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const asset = await Asset.create({
|
||||
@@ -434,10 +441,18 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or
|
||||
storage_key,
|
||||
is_public,
|
||||
createdBy,
|
||||
transcode_status: needsTranscode ? "pending" : "none",
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
|
||||
|
||||
if (needsTranscode) {
|
||||
assetTranscode.transcodeAsset(asset).catch((err) => {
|
||||
console.error("[ASSET][TRANSCODE] Background remux failed to start:", err.message);
|
||||
});
|
||||
}
|
||||
|
||||
return asset;
|
||||
|
||||
} catch (dbErr) {
|
||||
|
||||
@@ -20,6 +20,7 @@ const CourseReadingProgress = require('../../models/courses/course_reading_progr
|
||||
const { Course, Unit, Lesson, CourseUnit } = require('../../models/courses/courses.associations');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
|
||||
const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
@@ -91,21 +92,22 @@ exports.getCourseReadingProgress = async (req, res) => {
|
||||
if (row.type === 'lesson' && row.status === 'completed') entry.lessons_completed++;
|
||||
}
|
||||
|
||||
const result = Object.values(summaryMap).map((entry) => {
|
||||
const result = await Promise.all(Object.values(summaryMap).map(async (entry) => {
|
||||
const u = userMap[entry.user_id];
|
||||
const avatar = await resolveAvatarUrl(u?.personal_info?.avatar);
|
||||
return {
|
||||
...entry,
|
||||
user: {
|
||||
email: u?.email ?? null,
|
||||
full_name: u?.personal_info?.name?.full_name ?? null,
|
||||
avatar_url: u?.personal_info?.avatar?.url ?? null,
|
||||
avatar_url: avatar?.url ?? null,
|
||||
},
|
||||
units_total,
|
||||
lessons_total,
|
||||
// Fall back to in_progress if the course row hasn't been written yet
|
||||
course_status: entry.course_status ?? 'in_progress',
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
// Sort: completed last, most recent first within each group
|
||||
result.sort((a, b) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ const sequelize = require("../../config/db.config");
|
||||
const R = require("../../utils/response.util");
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { recomputeDurations, recomputeUnitDuration, recomputeCourseDuration, formatDuration } = require("../../utils/duration.util");
|
||||
const { resolveAvatarUrl } = require("../../utils/resolveAvatar.util");
|
||||
const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util");
|
||||
const { resolvePrerequisiteTitles } = require("../../utils/courses/resolvePrerequisiteTitles.util");
|
||||
const { syncJunction } = require("../../utils/courses/junction.util");
|
||||
@@ -2653,22 +2654,23 @@ exports.syncInstructors = async (req, res) => {
|
||||
|
||||
// ─── COMPLETIONS HELPERS ──────────────────────────────────────────────────────
|
||||
|
||||
function extractUserInfo(user) {
|
||||
async function extractUserInfo(user) {
|
||||
if (!user) return { full_name: null, email: null, avatar_url: null, deleted: false };
|
||||
const avatar = await resolveAvatarUrl(user.personal_info?.avatar);
|
||||
return {
|
||||
full_name: user.personal_info?.name?.full_name ?? null,
|
||||
email: user.email ?? null,
|
||||
avatar_url: user.personal_info?.avatar?.url ?? null,
|
||||
avatar_url: avatar?.url ?? null,
|
||||
deleted: !!user.deletedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function groupByUser(attempts) {
|
||||
async function groupByUser(attempts) {
|
||||
const map = new Map();
|
||||
for (const a of attempts) {
|
||||
const uid = String(a.user_id);
|
||||
if (!map.has(uid)) {
|
||||
const { full_name, email, avatar_url, deleted } = extractUserInfo(a.user);
|
||||
const { full_name, email, avatar_url, deleted } = await extractUserInfo(a.user);
|
||||
map.set(uid, {
|
||||
user_id: a.user_id,
|
||||
full_name,
|
||||
@@ -2749,7 +2751,7 @@ exports.getQuizCompletions = async (req, res) => {
|
||||
const plain = attempts.map((a) => a.toJSON());
|
||||
return R.success(res, "Quiz completions retrieved.", {
|
||||
summary: buildSummary(plain),
|
||||
completions: groupByUser(plain),
|
||||
completions: await groupByUser(plain),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][QUIZ][COMPLETIONS]", err);
|
||||
@@ -2773,7 +2775,7 @@ exports.getAssessmentCompletions = async (req, res) => {
|
||||
const plain = attempts.map((a) => a.toJSON());
|
||||
return R.success(res, "Assessment completions retrieved.", {
|
||||
summary: buildSummary(plain),
|
||||
completions: groupByUser(plain),
|
||||
completions: await groupByUser(plain),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][ASSESSMENT][COMPLETIONS]", err);
|
||||
@@ -2794,9 +2796,9 @@ exports.getAssessmentSessions = async (req, res) => {
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
const rows = sessions.map((s) => {
|
||||
const rows = await Promise.all(sessions.map(async (s) => {
|
||||
const j = s.toJSON();
|
||||
const { full_name, email, avatar_url, deleted } = extractUserInfo(j.user);
|
||||
const { full_name, email, avatar_url, deleted } = await extractUserInfo(j.user);
|
||||
const time_spent_seconds = j.status !== 'in_progress' && j.started_at
|
||||
? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000)
|
||||
: null;
|
||||
@@ -2813,7 +2815,7 @@ exports.getAssessmentSessions = async (req, res) => {
|
||||
time_spent_seconds,
|
||||
attempt_id: j.attempt_id,
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
const summary = {
|
||||
total_sessions: rows.length,
|
||||
|
||||
@@ -37,6 +37,7 @@ const {
|
||||
} = require("../../models/courses/courses.associations");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
const { mdl_PlanLessons, mdl_TierPlans } = require("../../models/tiers/tier.associations");
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||
@@ -155,6 +156,46 @@ exports.getLessonsFlat = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Bundle picker (Tier Plan wizard) — mirrors getCoursesBySubscription in
|
||||
// controllers/admin/courses.controller.js.
|
||||
exports.getLessonsBySubscription = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
if (!slug) return R.error(res, 'slug query param is required.', 400);
|
||||
|
||||
const rows = await Lesson.findAll({
|
||||
where: { ...notDeleted, subscription: slug },
|
||||
attributes: ['lesson_id', 'title', 'description', 'subscription'],
|
||||
include: [{
|
||||
model: mdl_PlanLessons,
|
||||
as: 'planLesson',
|
||||
required: false,
|
||||
attributes: ['plan_id'],
|
||||
include: [{
|
||||
model: mdl_TierPlans,
|
||||
as: 'plan',
|
||||
attributes: ['plan_id', 'label'],
|
||||
}],
|
||||
}],
|
||||
order: [['title', 'ASC']],
|
||||
});
|
||||
|
||||
// Flatten so the frontend can just check `assigned_plan` — a lesson
|
||||
// belongs to at most one plan (UNIQUE constraint on plan_lessons.lesson_id).
|
||||
const data = rows.map((l) => {
|
||||
const plain = l.toJSON();
|
||||
const assigned_plan = plain.planLesson?.plan ?? null;
|
||||
delete plain.planLesson;
|
||||
return { ...plain, assigned_plan };
|
||||
});
|
||||
|
||||
return R.success(res, 'Lessons retrieved.', data);
|
||||
} catch (err) {
|
||||
console.error('[LESSON LIB][BY SUBSCRIPTION]', err);
|
||||
return R.error(res, 'Could not retrieve lessons.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getLesson = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
@@ -179,11 +220,12 @@ exports.getLesson = async (req, res) => {
|
||||
exports.createLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { title, description, unit_id, order, objectives = [], createdBy } = req.body;
|
||||
const { title, description, subscription, unit_id, order, objectives = [], createdBy } = req.body;
|
||||
if (!title) return R.error(res, "Title is required.", 400);
|
||||
|
||||
const lesson = await Lesson.create({
|
||||
title,
|
||||
subscription: subscription || null,
|
||||
description: description ?? null,
|
||||
duration_seconds: 0,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
@@ -231,10 +273,11 @@ exports.updateLesson = async (req, res) => {
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
|
||||
const { title, description, objectives, updatedBy } = req.body;
|
||||
const { title, description, subscription, objectives, updatedBy } = req.body;
|
||||
|
||||
if (title !== undefined) lesson.title = title;
|
||||
if (description !== undefined) lesson.description = description;
|
||||
if (subscription !== undefined) lesson.subscription = subscription || null;
|
||||
lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
||||
await lesson.save({ transaction: t });
|
||||
|
||||
|
||||
@@ -5,54 +5,94 @@ const { Course, CourseProductCategory: mdl_CourseProductCategory } = require('..
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
// ─── PRODUCT (per course) ─────────────────────────────────────────────────────
|
||||
// ─── PRODUCT (generic, keyed by purchasable_type + purchasable_id) ───────────
|
||||
// Course/Unit/Lesson each get their own thin route + exported handler below,
|
||||
// all delegating to these so the CRUD logic isn't tripled across the three
|
||||
// content types — see routes/admin/products.routes.js.
|
||||
|
||||
exports.getCourseProduct = async (req, res) => {
|
||||
try {
|
||||
const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId }, paranoid: false });
|
||||
return R.success(res, 'Product retrieved.', product ?? null);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PRODUCTS][GET]', err);
|
||||
return R.error(res, 'Could not retrieve product.', 500);
|
||||
async function getProductFor(purchasable_type, purchasable_id) {
|
||||
return mdl_Product.findOne({ where: { purchasable_type, purchasable_id }, paranoid: false });
|
||||
}
|
||||
|
||||
async function upsertProductFor(purchasable_type, purchasable_id, body, adminUserId) {
|
||||
const { name, description, price, currency, access_days, is_active } = body;
|
||||
if (!name || price == null) {
|
||||
const err = new Error('name and price are required.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
exports.upsertCourseProduct = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const { name, description, price, currency, access_days, is_active } = req.body;
|
||||
if (!name || price == null) return R.error(res, 'name and price are required.', 400);
|
||||
|
||||
const existing = await mdl_Product.findOne({ where: { course_id: courseId }, paranoid: false });
|
||||
const existing = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id }, paranoid: false });
|
||||
|
||||
if (existing) {
|
||||
if (existing.deletedAt) await existing.restore();
|
||||
await existing.update({ name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
|
||||
logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', details: { course_id: courseId, name } });
|
||||
return R.success(res, 'Product updated.', existing);
|
||||
logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: existing.id, details: { purchasable_type, purchasable_id, name } });
|
||||
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 });
|
||||
logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', entityId: product.product_id, details: { course_id: courseId, name } });
|
||||
return R.success(res, 'Product created.', product, 201);
|
||||
const product = await mdl_Product.create({ purchasable_type, purchasable_id, name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
|
||||
logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: product.id, details: { purchasable_type, purchasable_id, name } });
|
||||
return { product, created: true };
|
||||
}
|
||||
|
||||
async function removeProductFor(purchasable_type, purchasable_id, adminUserId) {
|
||||
const product = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id } });
|
||||
if (!product) return false;
|
||||
await product.destroy();
|
||||
logActivity(adminUserId, `remove_${purchasable_type}_product`, { entityType: 'product', details: { purchasable_type, purchasable_id } });
|
||||
return true;
|
||||
}
|
||||
|
||||
function makeProductHandlers(purchasable_type, paramName) {
|
||||
return {
|
||||
get: async (req, res) => {
|
||||
try {
|
||||
const product = await getProductFor(purchasable_type, req.params[paramName]);
|
||||
return R.success(res, 'Product retrieved.', product ?? null);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PRODUCTS][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);
|
||||
}
|
||||
};
|
||||
|
||||
exports.removeCourseProduct = async (req, res) => {
|
||||
},
|
||||
remove: async (req, res) => {
|
||||
try {
|
||||
const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId } });
|
||||
if (!product) return R.error(res, 'Product not found.', 404);
|
||||
await product.destroy();
|
||||
logActivity(req.user?.user_id, 'remove_course_product', { entityType: 'product', details: { course_id: req.params.courseId } });
|
||||
const removed = await removeProductFor(purchasable_type, req.params[paramName], req.user?.user_id);
|
||||
if (!removed) return R.error(res, 'Product not found.', 404);
|
||||
return R.success(res, 'Product removed.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PRODUCTS][REMOVE]', err);
|
||||
console.error(`[ADMIN][PRODUCTS][REMOVE][${purchasable_type}]`, err);
|
||||
return R.error(res, 'Could not remove product.', 500);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const courseProductHandlers = makeProductHandlers('course', 'courseId');
|
||||
const unitProductHandlers = makeProductHandlers('unit', 'unitId');
|
||||
const lessonProductHandlers = makeProductHandlers('lesson', 'lessonId');
|
||||
|
||||
exports.getCourseProduct = courseProductHandlers.get;
|
||||
exports.upsertCourseProduct = courseProductHandlers.upsert;
|
||||
exports.removeCourseProduct = courseProductHandlers.remove;
|
||||
|
||||
exports.getUnitProduct = unitProductHandlers.get;
|
||||
exports.upsertUnitProduct = unitProductHandlers.upsert;
|
||||
exports.removeUnitProduct = unitProductHandlers.remove;
|
||||
|
||||
exports.getLessonProduct = lessonProductHandlers.get;
|
||||
exports.upsertLessonProduct = lessonProductHandlers.upsert;
|
||||
exports.removeLessonProduct = lessonProductHandlers.remove;
|
||||
|
||||
// ─── CATEGORIES (per course) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const { uploadFile, deleteFile } = require('../../services/s3.service');
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
// ─── GET own profile ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,7 +27,7 @@ exports.getProfile = async (req, res) => {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
return R.success(res, 'Profile retrieved.', user);
|
||||
return R.success(res, 'Profile retrieved.', await resolveUserAvatar(user));
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve profile.', 500);
|
||||
}
|
||||
@@ -53,7 +54,7 @@ exports.updateProfile = async (req, res) => {
|
||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
return R.success(res, 'Profile updated.', updated);
|
||||
return R.success(res, 'Profile updated.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN] updateProfile error:', err);
|
||||
return R.error(res, 'Profile update failed.', 500);
|
||||
@@ -95,7 +96,7 @@ exports.uploadAvatar = async (req, res) => {
|
||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
return R.success(res, 'Avatar updated.', updated);
|
||||
return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN] uploadAvatar error:', err);
|
||||
return R.error(res, 'Avatar upload failed.', 500);
|
||||
|
||||
@@ -16,7 +16,11 @@ const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl');
|
||||
const mdl_PlanUnits = require('../../models/tiers/plan_units.mdl');
|
||||
const mdl_PlanLessons = require('../../models/tiers/plan_lessons.mdl');
|
||||
const { Course } = require('../../models/courses/courses.mdl');
|
||||
const Unit = require('../../models/courses/units.mdl');
|
||||
const Lesson = require('../../models/courses/lessons.mdl');
|
||||
|
||||
require('../../models/tiers/tier.associations');
|
||||
|
||||
@@ -546,3 +550,107 @@ exports.syncPlanCourses = async (req, res) => {
|
||||
return R.error(res, 'Could not update plan courses.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PLAN UNITS ───────────────────────────────────────────────────────────────
|
||||
// Mirrors PLAN COURSES above — bundling/display only, not an access-control
|
||||
// mechanism (see canAccessUnit in controllers/client/courses.controller.js).
|
||||
|
||||
exports.getPlanUnits = async (req, res) => {
|
||||
try {
|
||||
const entries = await mdl_PlanUnits.findAll({
|
||||
where: { plan_id: req.params.id },
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: 'unit',
|
||||
attributes: ['unit_id', 'title', 'subscription'],
|
||||
}],
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Plan units retrieved.', entries.map(e => e.unit));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN UNITS]', err);
|
||||
return R.error(res, 'Could not retrieve plan units.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.syncPlanUnits = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { unit_ids = [] } = req.body;
|
||||
|
||||
if (!Array.isArray(unit_ids))
|
||||
return R.error(res, 'unit_ids must be an array.', 400);
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
await mdl_PlanUnits.destroy({ where: { plan_id: id } });
|
||||
|
||||
if (unit_ids.length) {
|
||||
// unit_id has a UNIQUE constraint — clear any orphaned/other-plan assignments
|
||||
// for these units before inserting, so the insert isn't silently skipped
|
||||
await mdl_PlanUnits.destroy({ where: { unit_id: unit_ids } });
|
||||
await mdl_PlanUnits.bulkCreate(
|
||||
unit_ids.map(unit_id => ({ plan_id: id, unit_id })),
|
||||
);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'sync_plan_units', { entityType: 'tier_plan', details: { plan_id: id, unit_ids, count: unit_ids.length } });
|
||||
return R.success(res, 'Plan units updated.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][SYNC PLAN UNITS]', err);
|
||||
return R.error(res, 'Could not update plan units.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PLAN LESSONS ─────────────────────────────────────────────────────────────
|
||||
// Mirrors PLAN COURSES above — bundling/display only, not an access-control
|
||||
// mechanism (see canAccessLesson in controllers/client/courses.controller.js).
|
||||
|
||||
exports.getPlanLessons = async (req, res) => {
|
||||
try {
|
||||
const entries = await mdl_PlanLessons.findAll({
|
||||
where: { plan_id: req.params.id },
|
||||
include: [{
|
||||
model: Lesson,
|
||||
as: 'lesson',
|
||||
attributes: ['lesson_id', 'title', 'subscription'],
|
||||
}],
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Plan lessons retrieved.', entries.map(e => e.lesson));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN LESSONS]', err);
|
||||
return R.error(res, 'Could not retrieve plan lessons.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.syncPlanLessons = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { lesson_ids = [] } = req.body;
|
||||
|
||||
if (!Array.isArray(lesson_ids))
|
||||
return R.error(res, 'lesson_ids must be an array.', 400);
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
await mdl_PlanLessons.destroy({ where: { plan_id: id } });
|
||||
|
||||
if (lesson_ids.length) {
|
||||
// lesson_id has a UNIQUE constraint — clear any orphaned/other-plan assignments
|
||||
// for these lessons before inserting, so the insert isn't silently skipped
|
||||
await mdl_PlanLessons.destroy({ where: { lesson_id: lesson_ids } });
|
||||
await mdl_PlanLessons.bulkCreate(
|
||||
lesson_ids.map(lesson_id => ({ plan_id: id, lesson_id })),
|
||||
);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'sync_plan_lessons', { entityType: 'tier_plan', details: { plan_id: id, lesson_ids, count: lesson_ids.length } });
|
||||
return R.success(res, 'Plan lessons updated.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][SYNC PLAN LESSONS]', err);
|
||||
return R.error(res, 'Could not update plan lessons.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ const CompletionRequirement = require("../../models/courses/completion_requireme
|
||||
const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
const { mdl_PlanUnits, mdl_TierPlans } = require("../../models/tiers/tier.associations");
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||
@@ -160,6 +161,46 @@ exports.getUnitsFlat = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Bundle picker (Tier Plan wizard) — mirrors getCoursesBySubscription in
|
||||
// controllers/admin/courses.controller.js.
|
||||
exports.getUnitsBySubscription = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
if (!slug) return R.error(res, 'slug query param is required.', 400);
|
||||
|
||||
const rows = await Unit.findAll({
|
||||
where: { ...notDeleted, subscription: slug },
|
||||
attributes: ['unit_id', 'title', 'description', 'subscription'],
|
||||
include: [{
|
||||
model: mdl_PlanUnits,
|
||||
as: 'planUnit',
|
||||
required: false,
|
||||
attributes: ['plan_id'],
|
||||
include: [{
|
||||
model: mdl_TierPlans,
|
||||
as: 'plan',
|
||||
attributes: ['plan_id', 'label'],
|
||||
}],
|
||||
}],
|
||||
order: [['title', 'ASC']],
|
||||
});
|
||||
|
||||
// Flatten so the frontend can just check `assigned_plan` — a unit belongs
|
||||
// to at most one plan (UNIQUE constraint on plan_units.unit_id).
|
||||
const data = rows.map((u) => {
|
||||
const plain = u.toJSON();
|
||||
const assigned_plan = plain.planUnit?.plan ?? null;
|
||||
delete plain.planUnit;
|
||||
return { ...plain, assigned_plan };
|
||||
});
|
||||
|
||||
return R.success(res, 'Units retrieved.', data);
|
||||
} catch (err) {
|
||||
console.error('[UNIT LIB][BY SUBSCRIPTION]', err);
|
||||
return R.error(res, 'Could not retrieve units.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnit = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
@@ -13,6 +13,7 @@ const { Op } = require('sequelize');
|
||||
const mdl_UserActivity = require('../../models/users/user_activity.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
const USER_ATTRS = [
|
||||
'user_id', 'email', 'acc_type',
|
||||
@@ -66,7 +67,7 @@ exports.getActivity = async (req, res) => {
|
||||
offset,
|
||||
});
|
||||
|
||||
const data = rows.map(formatRow);
|
||||
const data = await Promise.all(rows.map(formatRow));
|
||||
|
||||
return R.success(res, 'Activity feed retrieved.', {
|
||||
total: count,
|
||||
@@ -117,15 +118,16 @@ exports.getUserActivity = async (req, res) => {
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatRow(row) {
|
||||
async function formatRow(row) {
|
||||
const r = row.toJSON();
|
||||
const info = r.user?.personal_info;
|
||||
const avatar = await resolveAvatarUrl(info?.avatar);
|
||||
return {
|
||||
activity_id: r.activity_id,
|
||||
user_id: r.user_id,
|
||||
email: r.user?.email ?? null,
|
||||
full_name: info?.name?.full_name ?? null,
|
||||
avatar_url: info?.avatar?.url ?? null,
|
||||
avatar_url: avatar?.url ?? null,
|
||||
acc_type: r.user?.acc_type ?? null,
|
||||
action: r.action,
|
||||
entity_type: r.entity_type,
|
||||
|
||||
@@ -51,16 +51,17 @@ const { sendEmail } = require('../services/email.service');
|
||||
const buildSessionInfo = require('../utils/session_info.util');
|
||||
const logActivity = require('../utils/logActivity.util');
|
||||
const trustedDevice = require('../services/trustedDevice.service');
|
||||
const { resolveUserAvatar } = require('../utils/resolveAvatar.util');
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy'];
|
||||
|
||||
const safeUser = (user, extraExclude = []) => {
|
||||
const safeUser = async (user, extraExclude = []) => {
|
||||
const u = user.toJSON ? user.toJSON() : { ...user };
|
||||
|
||||
[...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]);
|
||||
|
||||
return u;
|
||||
return resolveUserAvatar(u);
|
||||
};
|
||||
|
||||
const setRefreshCookie = (res, refreshToken) => {
|
||||
@@ -321,7 +322,7 @@ exports.verifyOTP = async (req, res) => {
|
||||
return R.success(res, wasVerified ? 'Login successful.' : 'Email verified successfully. You are now logged in.', {
|
||||
accessToken,
|
||||
session_id: session.session_id,
|
||||
user: safeUser(user),
|
||||
user: await safeUser(user),
|
||||
});
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
@@ -403,7 +404,7 @@ exports.login = async (req, res) => {
|
||||
otpRequired: false,
|
||||
accessToken,
|
||||
session_id: session.session_id,
|
||||
user: safeUser(user),
|
||||
user: await safeUser(user),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -637,7 +638,7 @@ exports.refreshToken = async (req, res) => {
|
||||
// Check if refresh token is expired
|
||||
if (!shouldRotateRefreshToken(decoded)) {
|
||||
const { accessToken } = generateTokens(user);
|
||||
return R.success(res, 'Token refreshed.', { accessToken, session_id: session.session_id, user: safeUser(user) });
|
||||
return R.success(res, 'Token refreshed.', { accessToken, session_id: session.session_id, user: await safeUser(user) });
|
||||
}
|
||||
|
||||
// ─── Rotate refresh token ───────────────────────────────────────────────────
|
||||
@@ -646,7 +647,7 @@ exports.refreshToken = async (req, res) => {
|
||||
|
||||
setRefreshCookie(res, tokens.refreshToken);
|
||||
|
||||
return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: safeUser(user) });
|
||||
return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: await safeUser(user) });
|
||||
} catch (err) {
|
||||
console.error('[AUTH] refresh token error:', err);
|
||||
return R.error(res, 'Invalid or expired refresh token.', 401);
|
||||
|
||||
@@ -3,8 +3,12 @@ const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
|
||||
const mdl_Product = require('../../models/courses/products.mdl');
|
||||
const paymentSvc = require('../../services/payment.service');
|
||||
const R = require('../../utils/response.util');
|
||||
const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util');
|
||||
|
||||
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
|
||||
// Despite the "course" naming (historical — this predates Units/Lessons being
|
||||
// individually purchasable), this endpoint and table are generic: a Product's
|
||||
// purchasable_type/purchasable_id drives everything below.
|
||||
|
||||
exports.createCourseOrder = async (req, res) => {
|
||||
try {
|
||||
@@ -19,15 +23,19 @@ exports.createCourseOrder = async (req, res) => {
|
||||
});
|
||||
if (existing) {
|
||||
const stillActive = !existing.expires_at || new Date(existing.expires_at) > new Date();
|
||||
if (stillActive) return R.error(res, 'You already have active access to this course.', 409);
|
||||
if (stillActive) return R.error(res, `You already have active access to this ${product.purchasable_type}.`, 409);
|
||||
}
|
||||
|
||||
const target = await resolvePurchasable(product.purchasable_type, product.purchasable_id);
|
||||
if (!target) return R.error(res, 'Purchasable content not found.', 404);
|
||||
const path = checkoutPath(product.purchasable_type, target);
|
||||
|
||||
const ppOrder = await paymentSvc.createOrder('paypal', {
|
||||
amount: Number(product.price).toFixed(2),
|
||||
currency: product.currency,
|
||||
referenceId: `user_${req.user.user_id}_product_${product_id}`,
|
||||
returnUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout`,
|
||||
cancelUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout?cancelled=true`,
|
||||
returnUrl: `${process.env.FRONTEND_URL}${path}`,
|
||||
cancelUrl: `${process.env.FRONTEND_URL}${path}?cancelled=true`,
|
||||
});
|
||||
|
||||
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
|
||||
@@ -100,10 +108,14 @@ exports.captureCourseOrder = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Payment successful. Course access granted.', {
|
||||
const target = await resolvePurchasable(purchase.product.purchasable_type, purchase.product.purchasable_id);
|
||||
|
||||
return R.success(res, 'Payment successful. Access granted.', {
|
||||
purchase_id: purchase.id,
|
||||
expires_at: purchase.expires_at,
|
||||
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) {
|
||||
console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err);
|
||||
@@ -144,7 +156,7 @@ exports.getMyPurchases = async (req, res) => {
|
||||
try {
|
||||
const purchases = await mdl_CoursePurchase.findAll({
|
||||
where: { user_id: req.user.user_id },
|
||||
include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'course_id', 'access_days'] }],
|
||||
include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'purchasable_type', 'purchasable_id', 'access_days'] }],
|
||||
attributes: { exclude: ['provider_payload'] },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
@@ -127,6 +127,23 @@ async function buildUserContext(user_id) {
|
||||
return { tier, tierRankMap, rulesets, group_ids, activeTiers };
|
||||
}
|
||||
|
||||
// Individual-purchase check shared by all three content types — a Product is
|
||||
// keyed by (purchasable_type, purchasable_id), see utils/purchasable.util.js.
|
||||
async function hasActivePurchase(user_id, purchasable_type, purchasable_id) {
|
||||
const product = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id } });
|
||||
if (!product) return false;
|
||||
|
||||
const hasPurchase = await mdl_CoursePurchase.findOne({
|
||||
where: {
|
||||
user_id,
|
||||
product_id: product.id,
|
||||
status: 'completed',
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
});
|
||||
return !!hasPurchase;
|
||||
}
|
||||
|
||||
// ─── Shared tier + purchase access check ─────────────────────────────────────
|
||||
// Returns true → user may access the course.
|
||||
// Returns false → user's tier is too low AND no valid individual purchase.
|
||||
@@ -158,25 +175,16 @@ async function canAccessCourse(user_id, course_id) {
|
||||
}
|
||||
|
||||
// Individual purchase as fallback
|
||||
const product = await mdl_Product.findOne({ where: { course_id } });
|
||||
if (!product) return false;
|
||||
|
||||
const hasPurchase = await mdl_CoursePurchase.findOne({
|
||||
where: {
|
||||
user_id,
|
||||
product_id: product.id,
|
||||
status: 'completed',
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
});
|
||||
return !!hasPurchase;
|
||||
return hasActivePurchase(user_id, 'course', course_id);
|
||||
}
|
||||
|
||||
// ─── Standalone access checks (junction revamp) ──────────────────────────────
|
||||
// A Unit attached to no course is open to every authenticated user; a Unit
|
||||
// attached to one or more courses is open when the user can access ANY of them.
|
||||
// Lessons resolve through their parent units the same way. This keeps paid
|
||||
// content locked while letting genuinely standalone content run independently.
|
||||
// Lessons resolve the same way through their parent units. Both Unit and
|
||||
// Lesson also carry their own optional subscription/individual-purchase gate,
|
||||
// full parity with Course — this keeps paid content locked while letting
|
||||
// genuinely standalone content run independently.
|
||||
|
||||
async function canAccessUnit(user_id, unit_id) {
|
||||
// A unit's own subscription (standalone tier-gating) is an additional,
|
||||
@@ -195,6 +203,8 @@ async function canAccessUnit(user_id, unit_id) {
|
||||
if (allowed) return true;
|
||||
}
|
||||
|
||||
if (await hasActivePurchase(user_id, 'unit', unit_id)) return true;
|
||||
|
||||
// Only links to PUBLISHED courses count as a real course dependency — a unit
|
||||
// whose only link is to a draft/unpublished course behaves as if it had no
|
||||
// course link at all (falls through to the free/standalone branch below),
|
||||
@@ -212,8 +222,22 @@ async function canAccessUnit(user_id, unit_id) {
|
||||
}
|
||||
|
||||
async function canAccessLesson(user_id, lesson_id) {
|
||||
// Mirrors canAccessUnit's shape: own subscription, then own purchase, then
|
||||
// fall through to attached units (OR'd — a lesson can sit in more than one).
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
|
||||
if (lesson?.subscription) {
|
||||
const userCtx = await buildUserContext(user_id);
|
||||
const { allowed } = evaluateCourseAccess(
|
||||
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
|
||||
{ subscription: lesson.subscription }, userCtx.tierRankMap
|
||||
);
|
||||
if (allowed) return true;
|
||||
}
|
||||
|
||||
if (await hasActivePurchase(user_id, 'lesson', lesson_id)) return true;
|
||||
|
||||
const unitLinks = await UnitLesson.findAll({ where: { lesson_id }, attributes: ['unit_id'] });
|
||||
if (!unitLinks.length) return true;
|
||||
if (!unitLinks.length) return !lesson?.subscription;
|
||||
for (const link of unitLinks) {
|
||||
if (await canAccessUnit(user_id, link.unit_id)) return true;
|
||||
}
|
||||
@@ -280,17 +304,19 @@ exports.getCourses = async (req, res) => {
|
||||
const userCtx = await buildUserContext(req.user.user_id);
|
||||
const userTier = userCtx.tier;
|
||||
|
||||
// Fetch all completed purchases for this user (for has_purchased check)
|
||||
// Fetch all completed purchases for this user (for has_purchased check) —
|
||||
// course_purchases now spans all three content types, so filter down to
|
||||
// course-targeted products here.
|
||||
const myPurchases = await mdl_CoursePurchase.findAll({
|
||||
where: { user_id: req.user.user_id, status: 'completed' },
|
||||
include: [{ model: mdl_Product, as: 'product', attributes: ['course_id', 'access_days'] }],
|
||||
include: [{ model: mdl_Product, as: 'product', attributes: ['purchasable_type', 'purchasable_id', 'access_days'] }],
|
||||
attributes: ['id', 'expires_at', 'product_id'],
|
||||
});
|
||||
|
||||
const purchasedCourseIds = new Set(
|
||||
myPurchases
|
||||
.filter((p) => !p.expires_at || new Date(p.expires_at) > new Date())
|
||||
.map((p) => String(p.product?.course_id))
|
||||
.filter((p) => p.product?.purchasable_type === 'course' && (!p.expires_at || new Date(p.expires_at) > new Date()))
|
||||
.map((p) => String(p.product.purchasable_id))
|
||||
);
|
||||
|
||||
// Build category filter
|
||||
@@ -506,7 +532,7 @@ exports.getCourse = async (req, res) => {
|
||||
|
||||
// Attach product info and purchase status for the buy-course flow
|
||||
const product = await mdl_Product.findOne({
|
||||
where: { course_id: courseId, is_active: true },
|
||||
where: { purchasable_type: 'course', purchasable_id: courseId, is_active: true },
|
||||
attributes: ['id', 'name', 'price', 'currency', 'access_days'],
|
||||
});
|
||||
const hasPurchase = product && await mdl_CoursePurchase.findOne({
|
||||
@@ -1322,7 +1348,7 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
||||
const { uuid } = req.params;
|
||||
const unit = await Unit.findOne({
|
||||
where: { uuid, ...notDeleted },
|
||||
attributes: ["unit_id", "uuid", "title", "description", "duration_seconds"],
|
||||
attributes: ["unit_id", "uuid", "title", "subscription", "description", "duration_seconds"],
|
||||
include: [
|
||||
{
|
||||
model: Course, as: "courses",
|
||||
@@ -1353,10 +1379,12 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
||||
|
||||
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
|
||||
const first = unit.courses?.[0] ?? null;
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
|
||||
return res.status(403).json({
|
||||
status: "error",
|
||||
message: "You do not have access to this unit.",
|
||||
course: first ? { title: first.title, subscription: first.subscription } : null,
|
||||
item: { uuid: unit.uuid, subscription: unit.subscription, product, has_purchased },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1446,7 +1474,7 @@ exports.getLessonByUuid = async (req, res) => {
|
||||
const { uuid } = req.params;
|
||||
const lesson = await Lesson.findOne({
|
||||
where: { uuid, ...notDeleted },
|
||||
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
|
||||
attributes: ["lesson_id", "uuid", "title", "subscription", "description", "duration_seconds"],
|
||||
include: [
|
||||
{
|
||||
model: LessonPage,
|
||||
@@ -1480,10 +1508,12 @@ exports.getLessonByUuid = async (req, res) => {
|
||||
|
||||
if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) {
|
||||
const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null;
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
|
||||
return res.status(403).json({
|
||||
status: "error",
|
||||
message: "You do not have access to this lesson.",
|
||||
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
|
||||
item: { uuid: lesson.uuid, subscription: lesson.subscription, product, has_purchased },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1528,3 +1558,68 @@ exports.getLessonByUuid = async (req, res) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -21,6 +21,7 @@ const trustedDevice = require('../../services/trustedDevice.service');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { uploadFile, deleteFile } = require('../../services/s3.service');
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
// ─── GET own profile ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -29,7 +30,7 @@ exports.getProfile = async (req, res) => {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
return R.success(res, 'Profile retrieved.', user);
|
||||
return R.success(res, 'Profile retrieved.', await resolveUserAvatar(user));
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve profile.', 500);
|
||||
}
|
||||
@@ -60,7 +61,7 @@ exports.updateProfile = async (req, res) => {
|
||||
|
||||
logActivity(req.user.user_id, 'update_profile');
|
||||
|
||||
return R.success(res, 'Profile updated.', updated);
|
||||
return R.success(res, 'Profile updated.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
console.error('[CLIENT] updateProfile error:', err);
|
||||
return R.error(res, 'Profile update failed.', 500);
|
||||
@@ -140,7 +141,7 @@ exports.uploadAvatar = async (req, res) => {
|
||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
return R.success(res, 'Avatar updated.', updated);
|
||||
return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
console.error('[CLIENT] uploadAvatar error:', err);
|
||||
return R.error(res, 'Avatar upload failed.', 500);
|
||||
|
||||
@@ -184,12 +184,13 @@ exports.createOrder = async (req, res) => {
|
||||
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
|
||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
||||
|
||||
// Repurchasing a plan under a tier already held active is allowed — it
|
||||
// extends the existing grant's expires_at (see captureOrder) rather than
|
||||
// being blocked. Surfaced here only for checkout-page messaging.
|
||||
const existingActive = await mdl_UserTiers.findOne({
|
||||
where: { user_id: req.user.user_id, tier: plan.tier, status: 'active' },
|
||||
attributes: ['expires_at'],
|
||||
});
|
||||
if (existingActive) {
|
||||
return R.error(res, `You already have an active ${plan.tier} subscription until ${existingActive.expires_at}. You can repurchase once it expires.`, 409);
|
||||
}
|
||||
|
||||
const effectivePrice = Number(plan.price);
|
||||
const effectiveCurrency = plan.currency;
|
||||
@@ -249,6 +250,8 @@ exports.createOrder = async (req, res) => {
|
||||
currency: effectiveCurrency,
|
||||
promo_code: promoResult.code,
|
||||
discount: discount.toFixed(2),
|
||||
extends_existing: !!existingActive,
|
||||
current_expires_at: existingActive?.expires_at ?? null,
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][CREATE ORDER]', err);
|
||||
@@ -297,35 +300,30 @@ exports.captureOrder = async (req, res) => {
|
||||
|
||||
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
|
||||
// Scoped, defensive re-check: createOrder already blocked this, but time may
|
||||
// have passed (or two checkout tabs raced) between order creation and capture.
|
||||
// Money has already moved via PayPal at this point, so auto-refund rather than
|
||||
// leaving the user charged with nothing to show for it.
|
||||
// Repurchasing a plan under a tier already held active extends the
|
||||
// existing grant's expires_at by the new plan's duration, rather than
|
||||
// being blocked/refunded — the original plan_id is kept (whichever plan
|
||||
// first granted this tier keeps governing its bundle/access_rules; a
|
||||
// sibling-plan repurchase only adds time). This also keeps the
|
||||
// one-active-row-per-(user,tier) DB invariant intact, since no second
|
||||
// row is ever created.
|
||||
const existingActive = await mdl_UserTiers.findOne({
|
||||
where: { user_id: req.user.user_id, tier: payment.plan.tier, status: 'active' },
|
||||
});
|
||||
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 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,
|
||||
tier: payment.plan.tier,
|
||||
plan_id: payment.plan_id,
|
||||
@@ -334,10 +332,12 @@ exports.captureOrder = async (req, res) => {
|
||||
expires_at: expiresAt,
|
||||
granted_by: null,
|
||||
});
|
||||
successMessage = 'Payment successful. Tier activated.';
|
||||
}
|
||||
|
||||
await payment.update({
|
||||
status: 'completed',
|
||||
tier_id: newTier.tier_id,
|
||||
tier_id: resultTier.tier_id,
|
||||
paid_at: new Date(),
|
||||
provider_payload: {
|
||||
...payment.provider_payload,
|
||||
@@ -347,11 +347,14 @@ exports.captureOrder = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
await onTierActivated(req.user.user_id, newTier.tier);
|
||||
// Idempotent (grantAchievement checks for an existing row first) — safe
|
||||
// to call again on an extension, won't grant a duplicate achievement.
|
||||
await onTierActivated(req.user.user_id, resultTier.tier);
|
||||
|
||||
return R.success(res, 'Payment successful. Tier activated.', {
|
||||
tier: newTier.tier,
|
||||
expires_at: newTier.expires_at,
|
||||
return R.success(res, successMessage, {
|
||||
tier: resultTier.tier,
|
||||
expires_at: resultTier.expires_at,
|
||||
extended: !!existingActive,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][CAPTURE ORDER]', err);
|
||||
|
||||
@@ -30,9 +30,12 @@
|
||||
***********************************************************************************************************************************************************************/
|
||||
"use strict";
|
||||
|
||||
const { Op } = require("sequelize");
|
||||
const R = require("../../utils/response.util");
|
||||
const logActivity = require("../../utils/logActivity.util");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const mdl_Product = require("../../models/courses/products.mdl");
|
||||
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
|
||||
|
||||
const {
|
||||
Unit, Lesson,
|
||||
@@ -61,6 +64,35 @@ function sanitizeQuestions(questions = []) {
|
||||
});
|
||||
}
|
||||
|
||||
// Batch-fetch active product listings + this user's completed/unexpired
|
||||
// purchases for a set of standalone targets (unit or lesson), same shape as
|
||||
// the courses.controller.js equivalent — used by getUnits/getLessons below so
|
||||
// the browse-list Buy button has price data without an N+1 query per row.
|
||||
async function attachProducts(user_id, purchasable_type, ids) {
|
||||
if (!ids.length) return { productById: new Map(), purchasedIds: new Set() };
|
||||
|
||||
const products = await mdl_Product.findAll({
|
||||
where: { purchasable_type, purchasable_id: { [Op.in]: ids }, is_active: true },
|
||||
attributes: ["id", "name", "price", "currency", "access_days", "is_active", "purchasable_id"],
|
||||
});
|
||||
const productById = new Map(products.map((p) => [String(p.purchasable_id), p]));
|
||||
|
||||
const productIds = products.map((p) => p.id);
|
||||
const purchases = productIds.length ? await mdl_CoursePurchase.findAll({
|
||||
where: {
|
||||
user_id, product_id: { [Op.in]: productIds }, status: "completed",
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
attributes: ["product_id"],
|
||||
}) : [];
|
||||
const purchasedProductIds = new Set(purchases.map((p) => String(p.product_id)));
|
||||
const purchasedIds = new Set(
|
||||
products.filter((p) => purchasedProductIds.has(String(p.id))).map((p) => String(p.purchasable_id))
|
||||
);
|
||||
|
||||
return { productById, purchasedIds };
|
||||
}
|
||||
|
||||
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
|
||||
|
||||
// Client-side Units/Lessons browsing shows ALL content, bound to a course or
|
||||
@@ -104,6 +136,8 @@ exports.getUnits = async (req, res) => {
|
||||
coursesByUnit.set(row.unit_id, list);
|
||||
}
|
||||
|
||||
const { productById, purchasedIds } = await attachProducts(req.user.user_id, "unit", unitIds);
|
||||
|
||||
// is_locked mirrors canAccessUnit: a unit with its own subscription or at
|
||||
// least one attached course needs an access check; a fully open standalone
|
||||
// unit (no subscription, no course links) is never locked.
|
||||
@@ -112,7 +146,13 @@ exports.getUnits = async (req, res) => {
|
||||
const is_locked = (row.subscription || Number(row.course_count) > 0)
|
||||
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
|
||||
: false;
|
||||
result.push({ ...row, courses: coursesByUnit.get(row.unit_id) ?? [], is_locked });
|
||||
result.push({
|
||||
...row,
|
||||
courses: coursesByUnit.get(row.unit_id) ?? [],
|
||||
is_locked,
|
||||
product: productById.get(String(row.unit_id)) ?? null,
|
||||
has_purchased: purchasedIds.has(String(row.unit_id)),
|
||||
});
|
||||
}
|
||||
|
||||
return R.success(res, "Units retrieved.", result);
|
||||
@@ -131,7 +171,7 @@ exports.getLessons = async (req, res) => {
|
||||
try {
|
||||
const rows = await sequelize.query(`
|
||||
SELECT
|
||||
l.lesson_id, l.uuid, l.title, l.description, l.duration_seconds,
|
||||
l.lesson_id, l.uuid, l.title, l.subscription, l.description, l.duration_seconds,
|
||||
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = l.lesson_id) AS unit_count
|
||||
@@ -158,15 +198,23 @@ exports.getLessons = async (req, res) => {
|
||||
coursesByLesson.set(row.lesson_id, list);
|
||||
}
|
||||
|
||||
// is_locked mirrors canAccessLesson: standalone/unattached lessons are
|
||||
// open, attached lessons need at least one accessible course through
|
||||
// any attached unit.
|
||||
const { productById, purchasedIds } = await attachProducts(req.user.user_id, "lesson", lessonIds);
|
||||
|
||||
// is_locked mirrors canAccessLesson: a lesson with its own subscription or
|
||||
// at least one attached unit needs an access check; a fully open
|
||||
// standalone lesson (no subscription, no unit links) is never locked.
|
||||
const result = [];
|
||||
for (const row of rows) {
|
||||
const is_locked = Number(row.unit_count) > 0
|
||||
const is_locked = (row.subscription || Number(row.unit_count) > 0)
|
||||
? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
|
||||
: false;
|
||||
result.push({ ...row, courses: coursesByLesson.get(row.lesson_id) ?? [], is_locked });
|
||||
result.push({
|
||||
...row,
|
||||
courses: coursesByLesson.get(row.lesson_id) ?? [],
|
||||
is_locked,
|
||||
product: productById.get(String(row.lesson_id)) ?? null,
|
||||
has_purchased: purchasedIds.has(String(row.lesson_id)),
|
||||
});
|
||||
}
|
||||
|
||||
return R.success(res, "Lessons retrieved.", result);
|
||||
@@ -481,3 +529,5 @@ exports.markStandaloneLessonComplete = async (req, res) => {
|
||||
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
|
||||
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
|
||||
exports.getLessonByUuid = coursesCtrl.getLessonByUuid;
|
||||
exports.getUnitCheckoutInfo = coursesCtrl.getUnitCheckoutInfo;
|
||||
exports.getLessonCheckoutInfo = coursesCtrl.getLessonCheckoutInfo;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
* Currently registered:
|
||||
* - taskOverdue (cron/jobs/task_overdue.cron.js) — settings-backed
|
||||
* - liftExpiredBans (cron/jobs/lift_expired_bans.cron.js)
|
||||
* - retryStuckTranscodes (cron/jobs/retry_stuck_transcodes.cron.js)
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 17, 2026
|
||||
@@ -30,6 +31,7 @@
|
||||
const cron = require('node-cron');
|
||||
const taskOverdue = require('./jobs/task_overdue.cron');
|
||||
const liftExpiredBans = require('./jobs/lift_expired_bans.cron');
|
||||
const retryStuckTranscodes = require('./jobs/retry_stuck_transcodes.cron');
|
||||
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
||||
|
||||
// ─── Registry — add future admin-side cron jobs here ─────────────────────────
|
||||
@@ -40,6 +42,7 @@ const settingsBackedJobs = [
|
||||
// Plain hardcoded-schedule jobs (not tied to any notification setting).
|
||||
const plainJobs = [
|
||||
liftExpiredBans,
|
||||
retryStuckTranscodes,
|
||||
];
|
||||
|
||||
// ─── Boot all registered admin-side jobs ──────────────────────────────────────
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -39,6 +39,13 @@ const Asset = sequelize.define("Asset", {
|
||||
thumbnail_url: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true },
|
||||
thumbnail_storage_key: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true },
|
||||
|
||||
// ─── Background remux (.mov/.mkv → faststart .mp4) ───────────────────────
|
||||
// "none" — asset never needed a remux (not video, or already a fast format).
|
||||
// STRING, not ENUM — matches the STRING+CHECK column (see migration
|
||||
// 20270101000076), not a real Postgres enum type.
|
||||
transcode_status: { type: DataTypes.STRING(20), defaultValue: "none", label: "", order: 0, hidden: true },
|
||||
transcode_error: { type: DataTypes.TEXT, label: "", order: 0, hidden: true },
|
||||
|
||||
// ─── Description ──────────────────────────────────────────────────────────
|
||||
description: { type: DataTypes.TEXT, label: "Description", hidden: true },
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const Lesson = sequelize.define("Lesson", {
|
||||
|
||||
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1, filterable: true },
|
||||
// order 2 is reserved for the computed "Affiliated" (course_count) column, order 3 for computed "Course Status" — see LESSON_LIST_COMPUTED in lessons.controller.js
|
||||
subscription: { type: DataTypes.STRING(50), allowNull: true, label: "Subscription", hidden: false, order: 4, filterable: true, comment: "Optional direct tier gate for standalone lessons — null means open (or gated only via an attached unit)." },
|
||||
description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 0, filterable: false },
|
||||
|
||||
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, filterable: false }, // computed from blocks on save
|
||||
|
||||
@@ -2,10 +2,22 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
const { Course } = require('./courses.mdl');
|
||||
const Unit = require('./units.mdl');
|
||||
const Lesson = require('./lessons.mdl');
|
||||
|
||||
// Polymorphic target — a product is sold against exactly one Course, Unit, or
|
||||
// Lesson (purchasable_type + purchasable_id), not just courses. See
|
||||
// utils/purchasable.util.js for the type -> model/checkout-path resolver used
|
||||
// by every consumer (access checks, admin CRUD, checkout).
|
||||
//
|
||||
// constraints: false below because purchasable_id doesn't point at a single
|
||||
// table — referential integrity across the three possible targets is
|
||||
// enforced at the application layer only (see
|
||||
// 20270101000078-generalize-products-purchasable.js).
|
||||
const mdl_Product = sequelize.define('Product', {
|
||||
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
||||
course_id: { type: DataTypes.BIGINT, allowNull: false },
|
||||
purchasable_type: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'course' },
|
||||
purchasable_id: { type: DataTypes.BIGINT, allowNull: false },
|
||||
name: { type: DataTypes.STRING(200), allowNull: false },
|
||||
description: { type: DataTypes.TEXT, allowNull: true },
|
||||
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false },
|
||||
@@ -18,8 +30,12 @@ const mdl_Product = sequelize.define('Product', {
|
||||
paranoid: true,
|
||||
});
|
||||
|
||||
// A course has one product listing; a product belongs to one course
|
||||
mdl_Product.belongsTo(Course, { foreignKey: 'course_id', as: 'course' });
|
||||
Course.hasOne(mdl_Product, { foreignKey: 'course_id', as: 'product' });
|
||||
// Scoped hasOne per target type — Sequelize automatically adds the matching
|
||||
// purchasable_type filter to the join, so existing
|
||||
// `include: [{ model: mdl_Product, as: 'product' }]` call sites on Course
|
||||
// keep working unchanged.
|
||||
Course.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'course' }, as: 'product' });
|
||||
Unit.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'unit' }, as: 'product' });
|
||||
Lesson.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'lesson' }, as: 'product' });
|
||||
|
||||
module.exports = mdl_Product;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -4,10 +4,14 @@ const mdl_TierPlans = require('./tier_plans.mdl');
|
||||
const mdl_UserTiers = require('./user_tiers.mdl');
|
||||
const mdl_Payments = require('./payments.mdl');
|
||||
const mdl_PlanCourses = require('./plan_courses.mdl');
|
||||
const mdl_PlanUnits = require('./plan_units.mdl');
|
||||
const mdl_PlanLessons = require('./plan_lessons.mdl');
|
||||
const mdl_PlanPolicies = require('./plan_policies.mdl');
|
||||
const mdl_SystemBadges = require('../system_badges/system_badges.mdl');
|
||||
const Asset = require('../assets/assets.mdl');
|
||||
const { Course } = require('../courses/courses.mdl');
|
||||
const Unit = require('../courses/units.mdl');
|
||||
const Lesson = require('../courses/lessons.mdl');
|
||||
|
||||
// ─── TierCategory ─────────────────────────────────────────────────────────────
|
||||
mdl_TierCategories.belongsTo(Asset, { foreignKey: 'badge_asset_id', as: 'badgeAsset' });
|
||||
@@ -45,6 +49,42 @@ mdl_PlanCourses.belongsTo(Course, { foreignKey: 'course_id', as: 'course'
|
||||
Course.hasOne(mdl_PlanCourses, { as: 'planCourse', foreignKey: 'course_id' });
|
||||
mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_id' });
|
||||
|
||||
// ─── Plan ↔ Units ─────────────────────────────────────────────────────────────
|
||||
mdl_TierPlans.belongsToMany(Unit, {
|
||||
through: mdl_PlanUnits,
|
||||
foreignKey: 'plan_id',
|
||||
otherKey: 'unit_id',
|
||||
as: 'units',
|
||||
});
|
||||
Unit.belongsToMany(mdl_TierPlans, {
|
||||
through: mdl_PlanUnits,
|
||||
foreignKey: 'unit_id',
|
||||
otherKey: 'plan_id',
|
||||
as: 'plans',
|
||||
});
|
||||
mdl_PlanUnits.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
|
||||
mdl_PlanUnits.belongsTo(Unit, { foreignKey: 'unit_id', as: 'unit' });
|
||||
Unit.hasOne(mdl_PlanUnits, { as: 'planUnit', foreignKey: 'unit_id' });
|
||||
mdl_TierPlans.hasMany(mdl_PlanUnits, { as: 'planUnits', foreignKey: 'plan_id' });
|
||||
|
||||
// ─── Plan ↔ Lessons ───────────────────────────────────────────────────────────
|
||||
mdl_TierPlans.belongsToMany(Lesson, {
|
||||
through: mdl_PlanLessons,
|
||||
foreignKey: 'plan_id',
|
||||
otherKey: 'lesson_id',
|
||||
as: 'lessons',
|
||||
});
|
||||
Lesson.belongsToMany(mdl_TierPlans, {
|
||||
through: mdl_PlanLessons,
|
||||
foreignKey: 'lesson_id',
|
||||
otherKey: 'plan_id',
|
||||
as: 'plans',
|
||||
});
|
||||
mdl_PlanLessons.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
|
||||
mdl_PlanLessons.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesson' });
|
||||
Lesson.hasOne(mdl_PlanLessons, { as: 'planLesson', foreignKey: 'lesson_id' });
|
||||
mdl_TierPlans.hasMany(mdl_PlanLessons, { as: 'planLessons', foreignKey: 'plan_id' });
|
||||
|
||||
// ─── UserTier → Plan ──────────────────────────────────────────────────────────
|
||||
mdl_UserTiers.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
|
||||
mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' });
|
||||
@@ -62,6 +102,8 @@ module.exports = {
|
||||
mdl_UserTiers,
|
||||
mdl_Payments,
|
||||
mdl_PlanCourses,
|
||||
mdl_PlanUnits,
|
||||
mdl_PlanLessons,
|
||||
mdl_PlanPolicies,
|
||||
mdl_SystemBadges,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ const requirementsCtrl = require("../../controllers/admin/completion_requirement
|
||||
router.get("/", ctrl.getLessons);
|
||||
router.post("/", ctrl.createLesson);
|
||||
router.get("/flat", ctrl.getLessonsFlat);
|
||||
router.get("/by-subscription", ctrl.getLessonsBySubscription);
|
||||
router.get("/field-values", ctrl.getLessonFieldValues);
|
||||
router.delete("/bulk", ctrl.bulkArchiveLessons);
|
||||
router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteLessons);
|
||||
|
||||
@@ -7,6 +7,16 @@ router.get ('/courses/:courseId/product', ctrl.getCourseProduct);
|
||||
router.put ('/courses/:courseId/product', ctrl.upsertCourseProduct);
|
||||
router.delete('/courses/:courseId/product', ctrl.removeCourseProduct);
|
||||
|
||||
// Product per standalone unit
|
||||
router.get ('/units/:unitId/product', ctrl.getUnitProduct);
|
||||
router.put ('/units/:unitId/product', ctrl.upsertUnitProduct);
|
||||
router.delete('/units/:unitId/product', ctrl.removeUnitProduct);
|
||||
|
||||
// Product per standalone lesson
|
||||
router.get ('/lessons/:lessonId/product', ctrl.getLessonProduct);
|
||||
router.put ('/lessons/:lessonId/product', ctrl.upsertLessonProduct);
|
||||
router.delete('/lessons/:lessonId/product', ctrl.removeLessonProduct);
|
||||
|
||||
// Categories per course
|
||||
router.get ('/courses/:courseId/categories', ctrl.getCourseCategories);
|
||||
router.post ('/courses/:courseId/categories', ctrl.syncCourseCategories);
|
||||
|
||||
@@ -18,11 +18,15 @@ router.get ('/users/:id/tiers', ctrl.getUserTiers);
|
||||
router.post ('/users/tiers/grant', ctrl.grantTier);
|
||||
router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier);
|
||||
|
||||
// ← course + impact routes before /:id
|
||||
// ← course/unit/lesson + impact routes before /:id
|
||||
router.get ('/:id/impact', ctrl.getPlanImpact);
|
||||
router.get ('/:id/permanent-delete-impact', ctrl.getPlanPermanentDeleteImpact);
|
||||
router.get ('/:id/courses', ctrl.getPlanCourses);
|
||||
router.post ('/:id/courses', ctrl.syncPlanCourses);
|
||||
router.get ('/:id/units', ctrl.getPlanUnits);
|
||||
router.post ('/:id/units', ctrl.syncPlanUnits);
|
||||
router.get ('/:id/lessons', ctrl.getPlanLessons);
|
||||
router.post ('/:id/lessons', ctrl.syncPlanLessons);
|
||||
|
||||
router.get ('/:id', ctrl.getPlan);
|
||||
router.put ('/:id', ctrl.updatePlan);
|
||||
|
||||
@@ -20,6 +20,7 @@ router.get("/", ctrl.getUnits);
|
||||
router.post("/", ctrl.createUnit);
|
||||
router.post("/full", ctrl.createUnitFull);
|
||||
router.get("/flat", ctrl.getUnitsFlat);
|
||||
router.get("/by-subscription", ctrl.getUnitsBySubscription);
|
||||
router.get("/field-values", ctrl.getUnitFieldValues);
|
||||
router.delete("/bulk", ctrl.bulkArchiveUnits);
|
||||
router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteUnits);
|
||||
|
||||
@@ -23,6 +23,7 @@ router.get('/quiz/uuid/:uuid', ctrl.getQuizByUuid);
|
||||
|
||||
// Courses
|
||||
router.get('/', ctrl.getCourses);
|
||||
router.get('/:courseId/checkout-info', ctrl.getCourseCheckoutInfo);
|
||||
router.get('/:courseId', ctrl.getCourse);
|
||||
|
||||
// Unit
|
||||
|
||||
@@ -16,6 +16,7 @@ const router = express.Router();
|
||||
const ctrl = require('../../controllers/client/units.controller');
|
||||
|
||||
router.get('/', ctrl.getLessons);
|
||||
router.get('/:uuid/checkout-info', ctrl.getLessonCheckoutInfo);
|
||||
router.get('/:uuid', ctrl.getLessonByUuid);
|
||||
router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress);
|
||||
router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress);
|
||||
|
||||
@@ -19,6 +19,7 @@ const ctrl = require('../../controllers/client/units.controller');
|
||||
router.get('/', ctrl.getUnits);
|
||||
router.get('/:uuid/lessons', ctrl.getLessonsByUnitUuid);
|
||||
router.get('/:uuid/quiz', ctrl.getUnitQuiz);
|
||||
router.get('/:uuid/checkout-info', ctrl.getUnitCheckoutInfo);
|
||||
router.patch('/:uuid/quiz/:quizId/draft', ctrl.saveUnitQuizDraft);
|
||||
router.post('/:uuid/quiz/:quizId/submit', ctrl.submitUnitQuiz);
|
||||
router.get('/:uuid', ctrl.getUnitByUuid);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -184,6 +184,38 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "image",
|
||||
};
|
||||
}
|
||||
|
||||
// ─── uploadStream ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Same as uploadFile(), but Body is a Node Readable stream instead of a
|
||||
// Buffer — used by assetTranscode.service.js to push a remuxed video back to
|
||||
// storage straight off local disk, without ever holding the whole (possibly
|
||||
// multi-GB) file in this process's memory. Upload (lib-storage) auto-chunks
|
||||
// a stream body into multipart the same way it does a large Buffer.
|
||||
//
|
||||
// input: { stream, originalname, mimetype, ownerType? }
|
||||
// output: { url, uuid } ← uuid = the S3 key, stored as storage_key in DB
|
||||
//
|
||||
async function uploadStream({ stream, originalname, mimetype, ownerType = "video" }) {
|
||||
if (!stream) {
|
||||
throw Object.assign(new Error("A readable stream is required for S3 uploads."), { status: 400 });
|
||||
}
|
||||
|
||||
const bucket = DEFAULT_BUCKET;
|
||||
const key = buildKey(originalname, ownerType);
|
||||
|
||||
const uploader = new Upload({
|
||||
client: s3,
|
||||
params: { Bucket: bucket, Key: key, Body: stream, ContentType: mimetype },
|
||||
});
|
||||
|
||||
await uploader.done();
|
||||
|
||||
return {
|
||||
url: await buildPublicUrl(key, bucket),
|
||||
uuid: key,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── deleteFile ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Matches chibisafe.service.js interface.
|
||||
@@ -412,7 +444,7 @@ async function getFileMetadata(key) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadFile, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream,
|
||||
uploadFile, uploadStream, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream,
|
||||
presignUpload, completeMultipartUpload, abortMultipartUpload,
|
||||
getFileMetadata, buildPublicUrl, ping,
|
||||
};
|
||||
@@ -64,7 +64,7 @@ async function resolveCourseUserIds(courseUuid) {
|
||||
holders.forEach((h) => userIds.add(String(h.user_id)));
|
||||
}
|
||||
|
||||
const product = await mdl_Product.findOne({ where: { course_id: course.course_id } });
|
||||
const product = await mdl_Product.findOne({ where: { purchasable_type: 'course', purchasable_id: course.course_id } });
|
||||
if (product) {
|
||||
const purchasers = await mdl_CoursePurchase.findAll({
|
||||
attributes: ['user_id'],
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user