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 (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: { purchasable_type, purchasable_id }, paranoid: false });
|
||||
|
||||
const existing = await mdl_Product.findOne({ where: { course_id: courseId }, paranoid: false });
|
||||
|
||||
if (existing) {
|
||||
if (existing.deletedAt) await existing.restore();
|
||||
await existing.update({ name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
|
||||
logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', details: { course_id: courseId, name } });
|
||||
return R.success(res, 'Product updated.', existing);
|
||||
}
|
||||
|
||||
const product = await mdl_Product.create({ course_id: courseId, name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
|
||||
logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', entityId: product.product_id, details: { course_id: courseId, name } });
|
||||
return R.success(res, 'Product created.', product, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PRODUCTS][UPSERT]', err);
|
||||
return R.error(res, 'Could not save product.', 500);
|
||||
if (existing) {
|
||||
if (existing.deletedAt) await existing.restore();
|
||||
await existing.update({ name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
|
||||
logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: existing.id, details: { purchasable_type, purchasable_id, name } });
|
||||
return { product: existing, created: false };
|
||||
}
|
||||
};
|
||||
|
||||
exports.removeCourseProduct = async (req, res) => {
|
||||
try {
|
||||
const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId } });
|
||||
if (!product) return R.error(res, 'Product not found.', 404);
|
||||
await product.destroy();
|
||||
logActivity(req.user?.user_id, 'remove_course_product', { entityType: 'product', details: { course_id: req.params.courseId } });
|
||||
return R.success(res, 'Product removed.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PRODUCTS][REMOVE]', err);
|
||||
return R.error(res, 'Could not remove product.', 500);
|
||||
}
|
||||
};
|
||||
const product = await mdl_Product.create({ purchasable_type, purchasable_id, name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
|
||||
logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: product.id, details: { purchasable_type, purchasable_id, name } });
|
||||
return { product, created: true };
|
||||
}
|
||||
|
||||
async function removeProductFor(purchasable_type, purchasable_id, adminUserId) {
|
||||
const product = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id } });
|
||||
if (!product) return false;
|
||||
await product.destroy();
|
||||
logActivity(adminUserId, `remove_${purchasable_type}_product`, { entityType: 'product', details: { purchasable_type, purchasable_id } });
|
||||
return true;
|
||||
}
|
||||
|
||||
function makeProductHandlers(purchasable_type, paramName) {
|
||||
return {
|
||||
get: async (req, res) => {
|
||||
try {
|
||||
const product = await getProductFor(purchasable_type, req.params[paramName]);
|
||||
return R.success(res, 'Product retrieved.', product ?? null);
|
||||
} catch (err) {
|
||||
console.error(`[ADMIN][PRODUCTS][GET][${purchasable_type}]`, err);
|
||||
return R.error(res, 'Could not retrieve product.', 500);
|
||||
}
|
||||
},
|
||||
upsert: async (req, res) => {
|
||||
try {
|
||||
const { product, created } = await upsertProductFor(purchasable_type, req.params[paramName], req.body, req.user?.user_id);
|
||||
return R.success(res, created ? 'Product created.' : 'Product updated.', product, created ? 201 : 200);
|
||||
} catch (err) {
|
||||
if (err.status === 400) return R.error(res, err.message, 400);
|
||||
console.error(`[ADMIN][PRODUCTS][UPSERT][${purchasable_type}]`, err);
|
||||
return R.error(res, 'Could not save product.', 500);
|
||||
}
|
||||
},
|
||||
remove: async (req, res) => {
|
||||
try {
|
||||
const removed = await removeProductFor(purchasable_type, req.params[paramName], req.user?.user_id);
|
||||
if (!removed) return R.error(res, 'Product not found.', 404);
|
||||
return R.success(res, 'Product removed.');
|
||||
} catch (err) {
|
||||
console.error(`[ADMIN][PRODUCTS][REMOVE][${purchasable_type}]`, err);
|
||||
return R.error(res, 'Could not remove product.', 500);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const courseProductHandlers = makeProductHandlers('course', 'courseId');
|
||||
const unitProductHandlers = makeProductHandlers('unit', 'unitId');
|
||||
const lessonProductHandlers = makeProductHandlers('lesson', 'lessonId');
|
||||
|
||||
exports.getCourseProduct = courseProductHandlers.get;
|
||||
exports.upsertCourseProduct = courseProductHandlers.upsert;
|
||||
exports.removeCourseProduct = courseProductHandlers.remove;
|
||||
|
||||
exports.getUnitProduct = unitProductHandlers.get;
|
||||
exports.upsertUnitProduct = unitProductHandlers.upsert;
|
||||
exports.removeUnitProduct = unitProductHandlers.remove;
|
||||
|
||||
exports.getLessonProduct = lessonProductHandlers.get;
|
||||
exports.upsertLessonProduct = lessonProductHandlers.upsert;
|
||||
exports.removeLessonProduct = lessonProductHandlers.remove;
|
||||
|
||||
// ─── CATEGORIES (per course) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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.', {
|
||||
purchase_id: purchase.id,
|
||||
expires_at: purchase.expires_at,
|
||||
course_id: purchase.product.course_id,
|
||||
const target = await resolvePurchasable(purchase.product.purchasable_type, purchase.product.purchasable_id);
|
||||
|
||||
return R.success(res, 'Payment successful. Access granted.', {
|
||||
purchase_id: purchase.id,
|
||||
expires_at: purchase.expires_at,
|
||||
purchasable_type: purchase.product.purchasable_type,
|
||||
purchasable_id: purchase.product.purchasable_id,
|
||||
target_uuid: target?.uuid ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err);
|
||||
@@ -144,7 +156,7 @@ exports.getMyPurchases = async (req, res) => {
|
||||
try {
|
||||
const purchases = await mdl_CoursePurchase.findAll({
|
||||
where: { user_id: req.user.user_id },
|
||||
include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'course_id', 'access_days'] }],
|
||||
include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'purchasable_type', 'purchasable_id', 'access_days'] }],
|
||||
attributes: { exclude: ['provider_payload'] },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
@@ -127,6 +127,23 @@ async function buildUserContext(user_id) {
|
||||
return { tier, tierRankMap, rulesets, group_ids, activeTiers };
|
||||
}
|
||||
|
||||
// Individual-purchase check shared by all three content types — a Product is
|
||||
// keyed by (purchasable_type, purchasable_id), see utils/purchasable.util.js.
|
||||
async function hasActivePurchase(user_id, purchasable_type, purchasable_id) {
|
||||
const product = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id } });
|
||||
if (!product) return false;
|
||||
|
||||
const hasPurchase = await mdl_CoursePurchase.findOne({
|
||||
where: {
|
||||
user_id,
|
||||
product_id: product.id,
|
||||
status: 'completed',
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
});
|
||||
return !!hasPurchase;
|
||||
}
|
||||
|
||||
// ─── Shared tier + purchase access check ─────────────────────────────────────
|
||||
// Returns true → user may access the course.
|
||||
// Returns false → user's tier is too low AND no valid individual purchase.
|
||||
@@ -158,25 +175,16 @@ async function canAccessCourse(user_id, course_id) {
|
||||
}
|
||||
|
||||
// Individual purchase as fallback
|
||||
const product = await mdl_Product.findOne({ where: { course_id } });
|
||||
if (!product) return false;
|
||||
|
||||
const hasPurchase = await mdl_CoursePurchase.findOne({
|
||||
where: {
|
||||
user_id,
|
||||
product_id: product.id,
|
||||
status: 'completed',
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
});
|
||||
return !!hasPurchase;
|
||||
return hasActivePurchase(user_id, 'course', course_id);
|
||||
}
|
||||
|
||||
// ─── Standalone access checks (junction revamp) ──────────────────────────────
|
||||
// A Unit attached to no course is open to every authenticated user; a Unit
|
||||
// attached to one or more courses is open when the user can access ANY of them.
|
||||
// Lessons resolve through their parent units the same way. This keeps paid
|
||||
// content locked while letting genuinely standalone content run independently.
|
||||
// Lessons resolve the same way through their parent units. Both Unit and
|
||||
// Lesson also carry their own optional subscription/individual-purchase gate,
|
||||
// full parity with Course — this keeps paid content locked while letting
|
||||
// genuinely standalone content run independently.
|
||||
|
||||
async function canAccessUnit(user_id, unit_id) {
|
||||
// A unit's own subscription (standalone tier-gating) is an additional,
|
||||
@@ -195,6 +203,8 @@ async function canAccessUnit(user_id, unit_id) {
|
||||
if (allowed) return true;
|
||||
}
|
||||
|
||||
if (await hasActivePurchase(user_id, 'unit', unit_id)) return true;
|
||||
|
||||
// Only links to PUBLISHED courses count as a real course dependency — a unit
|
||||
// whose only link is to a draft/unpublished course behaves as if it had no
|
||||
// course link at all (falls through to the free/standalone branch below),
|
||||
@@ -212,8 +222,22 @@ async function canAccessUnit(user_id, unit_id) {
|
||||
}
|
||||
|
||||
async function canAccessLesson(user_id, lesson_id) {
|
||||
// Mirrors canAccessUnit's shape: own subscription, then own purchase, then
|
||||
// fall through to attached units (OR'd — a lesson can sit in more than one).
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
|
||||
if (lesson?.subscription) {
|
||||
const userCtx = await buildUserContext(user_id);
|
||||
const { allowed } = evaluateCourseAccess(
|
||||
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
|
||||
{ subscription: lesson.subscription }, userCtx.tierRankMap
|
||||
);
|
||||
if (allowed) return true;
|
||||
}
|
||||
|
||||
if (await hasActivePurchase(user_id, 'lesson', lesson_id)) return true;
|
||||
|
||||
const unitLinks = await UnitLesson.findAll({ where: { lesson_id }, attributes: ['unit_id'] });
|
||||
if (!unitLinks.length) return true;
|
||||
if (!unitLinks.length) return !lesson?.subscription;
|
||||
for (const link of unitLinks) {
|
||||
if (await canAccessUnit(user_id, link.unit_id)) return true;
|
||||
}
|
||||
@@ -280,17 +304,19 @@ exports.getCourses = async (req, res) => {
|
||||
const userCtx = await buildUserContext(req.user.user_id);
|
||||
const userTier = userCtx.tier;
|
||||
|
||||
// Fetch all completed purchases for this user (for has_purchased check)
|
||||
// Fetch all completed purchases for this user (for has_purchased check) —
|
||||
// course_purchases now spans all three content types, so filter down to
|
||||
// course-targeted products here.
|
||||
const myPurchases = await mdl_CoursePurchase.findAll({
|
||||
where: { user_id: req.user.user_id, status: 'completed' },
|
||||
include: [{ model: mdl_Product, as: 'product', attributes: ['course_id', 'access_days'] }],
|
||||
include: [{ model: mdl_Product, as: 'product', attributes: ['purchasable_type', 'purchasable_id', 'access_days'] }],
|
||||
attributes: ['id', 'expires_at', 'product_id'],
|
||||
});
|
||||
|
||||
const purchasedCourseIds = new Set(
|
||||
myPurchases
|
||||
.filter((p) => !p.expires_at || new Date(p.expires_at) > new Date())
|
||||
.map((p) => String(p.product?.course_id))
|
||||
.filter((p) => p.product?.purchasable_type === 'course' && (!p.expires_at || new Date(p.expires_at) > new Date()))
|
||||
.map((p) => String(p.product.purchasable_id))
|
||||
);
|
||||
|
||||
// Build category filter
|
||||
@@ -506,7 +532,7 @@ exports.getCourse = async (req, res) => {
|
||||
|
||||
// Attach product info and purchase status for the buy-course flow
|
||||
const product = await mdl_Product.findOne({
|
||||
where: { course_id: courseId, is_active: true },
|
||||
where: { purchasable_type: 'course', purchasable_id: courseId, is_active: true },
|
||||
attributes: ['id', 'name', 'price', 'currency', 'access_days'],
|
||||
});
|
||||
const hasPurchase = product && await mdl_CoursePurchase.findOne({
|
||||
@@ -1322,7 +1348,7 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
||||
const { uuid } = req.params;
|
||||
const unit = await Unit.findOne({
|
||||
where: { uuid, ...notDeleted },
|
||||
attributes: ["unit_id", "uuid", "title", "description", "duration_seconds"],
|
||||
attributes: ["unit_id", "uuid", "title", "subscription", "description", "duration_seconds"],
|
||||
include: [
|
||||
{
|
||||
model: Course, as: "courses",
|
||||
@@ -1353,10 +1379,12 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
||||
|
||||
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
|
||||
const first = unit.courses?.[0] ?? null;
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
|
||||
return res.status(403).json({
|
||||
status: "error",
|
||||
message: "You do not have access to this unit.",
|
||||
course: first ? { title: first.title, subscription: first.subscription } : null,
|
||||
item: { uuid: unit.uuid, subscription: unit.subscription, product, has_purchased },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1446,7 +1474,7 @@ exports.getLessonByUuid = async (req, res) => {
|
||||
const { uuid } = req.params;
|
||||
const lesson = await Lesson.findOne({
|
||||
where: { uuid, ...notDeleted },
|
||||
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
|
||||
attributes: ["lesson_id", "uuid", "title", "subscription", "description", "duration_seconds"],
|
||||
include: [
|
||||
{
|
||||
model: LessonPage,
|
||||
@@ -1480,10 +1508,12 @@ exports.getLessonByUuid = async (req, res) => {
|
||||
|
||||
if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) {
|
||||
const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null;
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
|
||||
return res.status(403).json({
|
||||
status: "error",
|
||||
message: "You do not have access to this lesson.",
|
||||
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
|
||||
item: { uuid: lesson.uuid, subscription: lesson.subscription, product, has_purchased },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1527,4 +1557,69 @@ exports.getLessonByUuid = async (req, res) => {
|
||||
console.error("[CLIENT][LESSONS][BY UUID]", err);
|
||||
return R.error(res, "Could not retrieve lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CHECKOUT INFO (course/unit/lesson) ───────────────────────────────────────
|
||||
// Deliberately does NOT hard-403 on locked content like getCourse/
|
||||
// getUnitByUuid/getLessonByUuid do — a locked-and-unpurchased item is exactly
|
||||
// who needs to land on this page and see title/description/product, so it
|
||||
// can't gate on the same canAccess*() check those content-serving routes use.
|
||||
// Auth-only; content stays fully protected behind the routes above.
|
||||
|
||||
const CHECKOUT_PK = { course: "course_id", unit: "unit_id", lesson: "lesson_id" };
|
||||
|
||||
async function buildCheckoutInfo(user_id, purchasable_type, record) {
|
||||
const product = await mdl_Product.findOne({
|
||||
where: { purchasable_type, purchasable_id: record[CHECKOUT_PK[purchasable_type]], is_active: true },
|
||||
attributes: ["id", "name", "price", "currency", "access_days"],
|
||||
});
|
||||
const hasPurchase = product && await mdl_CoursePurchase.findOne({
|
||||
where: {
|
||||
user_id, product_id: product.id, status: "completed",
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
});
|
||||
return { product: product ?? null, has_purchased: !!hasPurchase };
|
||||
}
|
||||
|
||||
exports.getCourseCheckoutInfo = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const course = await Course.findOne({
|
||||
where: { course_id: courseId, ...notDeleted, status: "published" },
|
||||
attributes: ["course_id", "uuid", "title", "description", "level", "subscription"],
|
||||
});
|
||||
if (!course) return R.error(res, "Course not found.", 404);
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "course", course);
|
||||
return R.success(res, "Checkout info retrieved.", { ...course.toJSON(), product, has_purchased });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][COURSES][CHECKOUT INFO]", err);
|
||||
return R.error(res, "Could not retrieve checkout info.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitCheckoutInfo = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ["unit_id", "uuid", "title", "description"] });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
|
||||
return R.success(res, "Checkout info retrieved.", { ...unit.toJSON(), product, has_purchased });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][UNITS][CHECKOUT INFO]", err);
|
||||
return R.error(res, "Could not retrieve checkout info.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getLessonCheckoutInfo = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid", "title", "description"] });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
|
||||
return R.success(res, "Checkout info retrieved.", { ...lesson.toJSON(), product, has_purchased });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][LESSONS][CHECKOUT INFO]", err);
|
||||
return R.error(res, "Could not retrieve checkout info.", 500);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -242,13 +243,15 @@ exports.createOrder = async (req, res) => {
|
||||
});
|
||||
|
||||
return R.success(res, 'Order created.', {
|
||||
payment_id: payment.payment_id,
|
||||
order_id: ppOrder.id,
|
||||
approval_url: approvalUrl,
|
||||
amount: total,
|
||||
currency: effectiveCurrency,
|
||||
promo_code: promoResult.code,
|
||||
discount: discount.toFixed(2),
|
||||
payment_id: payment.payment_id,
|
||||
order_id: ppOrder.id,
|
||||
approval_url: approvalUrl,
|
||||
amount: total,
|
||||
currency: effectiveCurrency,
|
||||
promo_code: promoResult.code,
|
||||
discount: discount.toFixed(2),
|
||||
extends_existing: !!existingActive,
|
||||
current_expires_at: existingActive?.expires_at ?? null,
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][CREATE ORDER]', err);
|
||||
@@ -297,47 +300,44 @@ exports.captureOrder = async (req, res) => {
|
||||
|
||||
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
|
||||
// Scoped, defensive re-check: createOrder already blocked this, but time may
|
||||
// have passed (or two checkout tabs raced) between order creation and capture.
|
||||
// Money has already moved via PayPal at this point, so auto-refund rather than
|
||||
// leaving the user charged with nothing to show for it.
|
||||
// Repurchasing a plan under a tier already held active extends the
|
||||
// existing grant's expires_at by the new plan's duration, rather than
|
||||
// being blocked/refunded — the original plan_id is kept (whichever plan
|
||||
// first granted this tier keeps governing its bundle/access_rules; a
|
||||
// sibling-plan repurchase only adds time). This also keeps the
|
||||
// one-active-row-per-(user,tier) DB invariant intact, since no second
|
||||
// row is ever created.
|
||||
const existingActive = await mdl_UserTiers.findOne({
|
||||
where: { user_id: req.user.user_id, tier: payment.plan.tier, status: 'active' },
|
||||
});
|
||||
|
||||
let resultTier;
|
||||
let successMessage;
|
||||
|
||||
if (existingActive) {
|
||||
try {
|
||||
await paymentSvc.refundCapture(payment.provider, capture?.id, payment.amount, payment.currency);
|
||||
await payment.update({
|
||||
status: 'refunded',
|
||||
provider_payload: { ...payment.provider_payload, capture_id: capture?.id, capture: captureData, error: 'duplicate_active_tier_auto_refunded' },
|
||||
});
|
||||
return R.error(res, `You already have an active ${payment.plan.tier} subscription. Your payment has been automatically refunded.`, 409);
|
||||
} catch (refundErr) {
|
||||
console.error('[CLIENT][CAPTURE ORDER] auto-refund failed for duplicate active tier:', refundErr?.response?.data ?? refundErr);
|
||||
await payment.update({
|
||||
status: 'failed',
|
||||
provider_payload: { ...payment.provider_payload, capture_id: capture?.id, capture: captureData, error: 'duplicate_active_tier_refund_failed' },
|
||||
});
|
||||
return R.error(res, `You already have an active ${payment.plan.tier} subscription. Refund could not be processed automatically — please contact support.`, 409);
|
||||
}
|
||||
const newExpiresAt = new Date(existingActive.expires_at.getTime() + payment.plan.duration_days * 86400000);
|
||||
await existingActive.update({ expires_at: newExpiresAt });
|
||||
resultTier = existingActive;
|
||||
successMessage = `Payment successful. Your ${payment.plan.tier} access has been extended to ${newExpiresAt.toLocaleDateString()}.`;
|
||||
} else {
|
||||
const startsAt = new Date();
|
||||
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
|
||||
|
||||
resultTier = await mdl_UserTiers.create({
|
||||
user_id: req.user.user_id,
|
||||
tier: payment.plan.tier,
|
||||
plan_id: payment.plan_id,
|
||||
status: 'active',
|
||||
starts_at: startsAt,
|
||||
expires_at: expiresAt,
|
||||
granted_by: null,
|
||||
});
|
||||
successMessage = 'Payment successful. Tier activated.';
|
||||
}
|
||||
|
||||
const startsAt = new Date();
|
||||
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
|
||||
|
||||
const newTier = await mdl_UserTiers.create({
|
||||
user_id: req.user.user_id,
|
||||
tier: payment.plan.tier,
|
||||
plan_id: payment.plan_id,
|
||||
status: 'active',
|
||||
starts_at: startsAt,
|
||||
expires_at: expiresAt,
|
||||
granted_by: null,
|
||||
});
|
||||
|
||||
await payment.update({
|
||||
status: 'completed',
|
||||
tier_id: newTier.tier_id,
|
||||
tier_id: resultTier.tier_id,
|
||||
paid_at: new Date(),
|
||||
provider_payload: {
|
||||
...payment.provider_payload,
|
||||
@@ -347,11 +347,14 @@ exports.captureOrder = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
await onTierActivated(req.user.user_id, newTier.tier);
|
||||
// Idempotent (grantAchievement checks for an existing row first) — safe
|
||||
// to call again on an extension, won't grant a duplicate achievement.
|
||||
await onTierActivated(req.user.user_id, resultTier.tier);
|
||||
|
||||
return R.success(res, 'Payment successful. Tier activated.', {
|
||||
tier: newTier.tier,
|
||||
expires_at: newTier.expires_at,
|
||||
return R.success(res, successMessage, {
|
||||
tier: resultTier.tier,
|
||||
expires_at: resultTier.expires_at,
|
||||
extended: !!existingActive,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][CAPTURE ORDER]', err);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user