mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -137,9 +137,9 @@ exports.getCourseReadingProgress = async (req, res) => {
|
||||
return {
|
||||
...entry,
|
||||
user: {
|
||||
email: u?.email ?? null,
|
||||
full_name: u?.personal_info?.name?.full_name ?? null,
|
||||
avatar_url: avatar?.url ?? null,
|
||||
email: u?.email ?? null,
|
||||
full_name: u?.personal_info?.name?.full_name ?? null,
|
||||
avatar_stream_token: avatar?.stream_token ?? null,
|
||||
},
|
||||
units_total,
|
||||
lessons_total,
|
||||
|
||||
@@ -2614,13 +2614,13 @@ exports.syncInstructors = async (req, res) => {
|
||||
// ─── COMPLETIONS HELPERS ──────────────────────────────────────────────────────
|
||||
|
||||
async function extractUserInfo(user) {
|
||||
if (!user) return { full_name: null, email: null, avatar_url: null, deleted: false };
|
||||
if (!user) return { full_name: null, email: null, avatar_stream_token: null, deleted: false };
|
||||
const avatar = await resolveAvatarUrl(user.personal_info?.avatar);
|
||||
return {
|
||||
full_name: user.personal_info?.name?.full_name ?? null,
|
||||
email: user.email ?? null,
|
||||
avatar_url: avatar?.url ?? null,
|
||||
deleted: !!user.deletedAt,
|
||||
full_name: user.personal_info?.name?.full_name ?? null,
|
||||
email: user.email ?? null,
|
||||
avatar_stream_token: avatar?.stream_token ?? null,
|
||||
deleted: !!user.deletedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2629,12 +2629,12 @@ async function groupByUser(attempts) {
|
||||
for (const a of attempts) {
|
||||
const uid = String(a.user_id);
|
||||
if (!map.has(uid)) {
|
||||
const { full_name, email, avatar_url, deleted } = await extractUserInfo(a.user);
|
||||
const { full_name, email, avatar_stream_token, deleted } = await extractUserInfo(a.user);
|
||||
map.set(uid, {
|
||||
user_id: a.user_id,
|
||||
full_name,
|
||||
email,
|
||||
avatar_url,
|
||||
avatar_stream_token,
|
||||
deleted,
|
||||
attempt_count: 0,
|
||||
best_score: 0,
|
||||
@@ -2757,7 +2757,7 @@ exports.getAssessmentSessions = async (req, res) => {
|
||||
|
||||
const rows = await Promise.all(sessions.map(async (s) => {
|
||||
const j = s.toJSON();
|
||||
const { full_name, email, avatar_url, deleted } = await extractUserInfo(j.user);
|
||||
const { full_name, email, avatar_stream_token, deleted } = await extractUserInfo(j.user);
|
||||
const time_spent_seconds = j.status !== 'in_progress' && j.started_at
|
||||
? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000)
|
||||
: null;
|
||||
@@ -2766,7 +2766,7 @@ exports.getAssessmentSessions = async (req, res) => {
|
||||
user_id: j.user_id,
|
||||
full_name,
|
||||
email,
|
||||
avatar_url,
|
||||
avatar_stream_token,
|
||||
deleted,
|
||||
status: j.status,
|
||||
started_at: j.started_at,
|
||||
|
||||
@@ -79,21 +79,11 @@ function makeProductHandlers(purchasable_type, paramName) {
|
||||
}
|
||||
|
||||
const courseProductHandlers = makeProductHandlers('course', 'courseId');
|
||||
const unitProductHandlers = makeProductHandlers('unit', 'unitId');
|
||||
const lessonProductHandlers = makeProductHandlers('lesson', 'lessonId');
|
||||
|
||||
exports.getCourseProduct = courseProductHandlers.get;
|
||||
exports.upsertCourseProduct = courseProductHandlers.upsert;
|
||||
exports.removeCourseProduct = courseProductHandlers.remove;
|
||||
|
||||
exports.getUnitProduct = unitProductHandlers.get;
|
||||
exports.upsertUnitProduct = unitProductHandlers.upsert;
|
||||
exports.removeUnitProduct = unitProductHandlers.remove;
|
||||
|
||||
exports.getLessonProduct = lessonProductHandlers.get;
|
||||
exports.upsertLessonProduct = lessonProductHandlers.upsert;
|
||||
exports.removeLessonProduct = lessonProductHandlers.remove;
|
||||
|
||||
// ─── CATEGORIES (per course) ──────────────────────────────────────────────────
|
||||
|
||||
exports.getCourseCategories = async (req, res) => {
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const { uploadFile, deleteFile } = require('../../services/s3.service');
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service');
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
// ─── GET own profile ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,28 +69,9 @@ exports.uploadAvatar = async (req, res) => {
|
||||
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
|
||||
// Remove old avatar from S3 before replacing
|
||||
const oldKey = user.personal_info?.avatar?.uuid;
|
||||
if (oldKey) await deleteFile(oldKey).catch(() => {});
|
||||
|
||||
const { url, uuid } = await uploadFile({
|
||||
buffer: req.file.buffer,
|
||||
originalname: req.file.originalname,
|
||||
mimetype: req.file.mimetype,
|
||||
ownerType: 'avatar',
|
||||
});
|
||||
|
||||
const merged = {
|
||||
...(user.personal_info || {}),
|
||||
avatar: {
|
||||
url,
|
||||
uuid,
|
||||
name: req.file.originalname,
|
||||
mime_type: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
},
|
||||
};
|
||||
const avatarMeta = await replaceUserAvatar(user, req.file);
|
||||
|
||||
const merged = { ...(user.personal_info || {}), avatar: avatarMeta };
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
||||
@@ -98,6 +79,7 @@ exports.uploadAvatar = async (req, res) => {
|
||||
});
|
||||
return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
if (err.status === 400) return R.error(res, err.message, 400);
|
||||
console.error('[ADMIN] uploadAvatar error:', err);
|
||||
return R.error(res, 'Avatar upload failed.', 500);
|
||||
}
|
||||
@@ -108,16 +90,15 @@ exports.uploadAvatar = async (req, res) => {
|
||||
exports.deleteAvatar = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
const key = user.personal_info?.avatar?.uuid;
|
||||
if (!key) return R.error(res, 'No avatar to remove.', 404);
|
||||
|
||||
await deleteFile(key).catch(() => {});
|
||||
await removeUserAvatar(user);
|
||||
|
||||
const merged = { ...(user.personal_info || {}), avatar: null };
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
return R.success(res, 'Avatar removed.');
|
||||
} catch (err) {
|
||||
if (err.status === 404) return R.error(res, err.message, 404);
|
||||
console.error('[ADMIN] deleteAvatar error:', err);
|
||||
return R.error(res, 'Could not remove avatar.', 500);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const { paginate } = require('../../utils/paginate.util');
|
||||
const { getFieldValues } = require('../../utils/fieldValues.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { snapshotPlanGrants } = require('../../services/tierGrants.service');
|
||||
const { revokePlanSubscriberAccess } = require('../../services/planAccess.service');
|
||||
const { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk } = require('../../services/planAccess.service');
|
||||
|
||||
const {
|
||||
excludeAttributes: plansExclude,
|
||||
@@ -244,16 +244,14 @@ exports.bulkArchivePlans = async (req, res) => {
|
||||
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
|
||||
|
||||
// Archiving always force-revokes current subscribers' access (no refund) —
|
||||
// each plan fires its own tier_plan_access_revoked (needs each plan's own
|
||||
// label), not the old batched "access unaffected" tier_plan_archived notice.
|
||||
// one batched call across all selected plans (each still fires its own
|
||||
// tier_plan_access_revoked with its own label) instead of one revoke call
|
||||
// per plan, so this stays O(1) DB round trips regardless of selection size.
|
||||
let revoked_user_count = 0;
|
||||
for (const p of activePlans) {
|
||||
try {
|
||||
const { revoked_user_count: c } = await revokePlanSubscriberAccess(p, req.user?.user_id ?? null);
|
||||
revoked_user_count += c;
|
||||
} catch (revokeErr) {
|
||||
console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr);
|
||||
}
|
||||
try {
|
||||
({ revoked_user_count } = await revokePlanSubscriberAccessBulk(activePlans, req.user?.user_id ?? null));
|
||||
} catch (revokeErr) {
|
||||
console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length, revoked_user_count } });
|
||||
@@ -380,13 +378,10 @@ exports.bulkPermanentlyDeletePlans = async (req, res) => {
|
||||
const archivedIds = archivedPlans.map((p) => p.plan_id);
|
||||
|
||||
// Same reasoning as the single-delete path above: revoke any remaining
|
||||
// active subscribers per plan (each needs its own label for the
|
||||
// notification/email) before the records are gone for good.
|
||||
let revoked_user_count = 0;
|
||||
for (const p of archivedPlans) {
|
||||
const { revoked_user_count: c } = await revokePlanSubscriberAccess(p, req.user?.user_id ?? null);
|
||||
revoked_user_count += c;
|
||||
}
|
||||
// active subscribers (each plan still gets its own label on the
|
||||
// notification/email) before the records are gone for good — batched in
|
||||
// one call across all selected plans instead of one call per plan.
|
||||
const { revoked_user_count } = await revokePlanSubscriberAccessBulk(archivedPlans, req.user?.user_id ?? null);
|
||||
|
||||
// payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted
|
||||
// by default) — plans can't be force-destroyed while payment rows still
|
||||
|
||||
@@ -123,12 +123,12 @@ async function formatRow(row) {
|
||||
const info = r.user?.personal_info;
|
||||
const avatar = await resolveAvatarUrl(info?.avatar);
|
||||
return {
|
||||
activity_id: r.activity_id,
|
||||
user_id: r.user_id,
|
||||
email: r.user?.email ?? null,
|
||||
full_name: info?.name?.full_name ?? null,
|
||||
avatar_url: avatar?.url ?? null,
|
||||
acc_type: r.user?.acc_type ?? null,
|
||||
activity_id: r.activity_id,
|
||||
user_id: r.user_id,
|
||||
email: r.user?.email ?? null,
|
||||
full_name: info?.name?.full_name ?? null,
|
||||
avatar_stream_token: avatar?.stream_token ?? null,
|
||||
acc_type: r.user?.acc_type ?? null,
|
||||
action: r.action,
|
||||
entity_type: r.entity_type,
|
||||
entity_id: r.entity_id,
|
||||
|
||||
@@ -28,6 +28,7 @@ const R = require('../../utils/response.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: userComputed } = require('../../models/users/users.attributes');
|
||||
|
||||
@@ -119,7 +120,7 @@ exports.getUser = async (req, res) => {
|
||||
});
|
||||
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
return R.success(res, 'User retrieved.', user);
|
||||
return R.success(res, 'User retrieved.', await resolveUserAvatar(user));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USER]', err);
|
||||
return R.error(res, 'Could not retrieve user.', 500);
|
||||
|
||||
@@ -158,8 +158,6 @@ async function canAccessUnit(user_id, unit_id) {
|
||||
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] });
|
||||
if (unit?.subscription && await hasItemGrant(user_id, 'unit', unit_id)) return true;
|
||||
|
||||
if (await hasActivePurchase(user_id, 'unit', unit_id)) return true;
|
||||
|
||||
// Only links to PUBLISHED courses count as a real course dependency — a unit
|
||||
// whose only link is to a draft/unpublished course behaves as if it had no
|
||||
// course link at all (falls through to the free/standalone branch below),
|
||||
@@ -182,8 +180,6 @@ async function canAccessLesson(user_id, lesson_id) {
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
|
||||
if (lesson?.subscription && await hasItemGrant(user_id, 'lesson', lesson_id)) return true;
|
||||
|
||||
if (await hasActivePurchase(user_id, 'lesson', lesson_id)) return true;
|
||||
|
||||
const unitLinks = await UnitLesson.findAll({ where: { lesson_id }, attributes: ['unit_id'] });
|
||||
if (!unitLinks.length) return !lesson?.subscription || lesson.subscription === 'free';
|
||||
for (const link of unitLinks) {
|
||||
@@ -1311,12 +1307,11 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
||||
|
||||
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
|
||||
const first = unit.courses?.[0] ?? null;
|
||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
|
||||
return res.status(403).json({
|
||||
status: "error",
|
||||
message: "You do not have access to this unit.",
|
||||
course: first ? { title: first.title, subscription: first.subscription } : null,
|
||||
item: { uuid: unit.uuid, subscription: unit.subscription, product, has_purchased, purchase_eligible },
|
||||
item: { uuid: unit.uuid, subscription: unit.subscription },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1440,12 +1435,11 @@ exports.getLessonByUuid = async (req, res) => {
|
||||
|
||||
if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) {
|
||||
const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null;
|
||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
|
||||
return res.status(403).json({
|
||||
status: "error",
|
||||
message: "You do not have access to this lesson.",
|
||||
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
|
||||
item: { uuid: lesson.uuid, subscription: lesson.subscription, product, has_purchased, purchase_eligible },
|
||||
item: { uuid: lesson.uuid, subscription: lesson.subscription },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1491,18 +1485,16 @@ exports.getLessonByUuid = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CHECKOUT INFO (course/unit/lesson) ───────────────────────────────────────
|
||||
// Deliberately does NOT hard-403 on locked content like getCourse/
|
||||
// getUnitByUuid/getLessonByUuid do — a locked-and-unpurchased item is exactly
|
||||
// who needs to land on this page and see title/description/product, so it
|
||||
// can't gate on the same canAccess*() check those content-serving routes use.
|
||||
// Auth-only; content stays fully protected behind the routes above.
|
||||
|
||||
const CHECKOUT_PK = { course: "course_id", unit: "unit_id", lesson: "lesson_id" };
|
||||
// ─── CHECKOUT INFO (course) ────────────────────────────────────────────────
|
||||
// Deliberately does NOT hard-403 on locked content like getCourse does — a
|
||||
// locked-and-unpurchased course is exactly who needs to land on this page and
|
||||
// see title/description/product, so it can't gate on the same canAccess*()
|
||||
// check those content-serving routes use. Auth-only; content stays fully
|
||||
// protected behind the routes above.
|
||||
|
||||
async function buildCheckoutInfo(user_id, purchasable_type, record) {
|
||||
const product = await mdl_Product.findOne({
|
||||
where: { purchasable_type, purchasable_id: record[CHECKOUT_PK[purchasable_type]], is_active: true },
|
||||
where: { purchasable_type, purchasable_id: record.course_id, is_active: true },
|
||||
attributes: ["id", "name", "price", "currency", "access_days"],
|
||||
});
|
||||
const hasPurchase = product && await mdl_CoursePurchase.findOne({
|
||||
@@ -1528,30 +1520,4 @@ exports.getCourseCheckoutInfo = async (req, res) => {
|
||||
console.error("[CLIENT][COURSES][CHECKOUT INFO]", err);
|
||||
return R.error(res, "Could not retrieve checkout info.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitCheckoutInfo = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ["unit_id", "uuid", "title", "description", "subscription"] });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
|
||||
return R.success(res, "Checkout info retrieved.", { ...unit.toJSON(), product, has_purchased, purchase_eligible });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][UNITS][CHECKOUT INFO]", err);
|
||||
return R.error(res, "Could not retrieve checkout info.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getLessonCheckoutInfo = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid", "title", "description", "subscription"] });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
|
||||
return R.success(res, "Checkout info retrieved.", { ...lesson.toJSON(), product, has_purchased, purchase_eligible });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][LESSONS][CHECKOUT INFO]", err);
|
||||
return R.error(res, "Could not retrieve checkout info.", 500);
|
||||
}
|
||||
};
|
||||
@@ -19,9 +19,9 @@ const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||
const mdl_Achievements = require('../../models/users/achievements.mdl');
|
||||
const trustedDevice = require('../../services/trustedDevice.service');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { uploadFile, deleteFile } = require('../../services/s3.service');
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service');
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
// ─── GET own profile ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -114,28 +114,9 @@ exports.uploadAvatar = async (req, res) => {
|
||||
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
|
||||
// Remove old avatar from S3 before replacing
|
||||
const oldKey = user.personal_info?.avatar?.uuid;
|
||||
if (oldKey) await deleteFile(oldKey).catch(() => {});
|
||||
|
||||
const { url, uuid } = await uploadFile({
|
||||
buffer: req.file.buffer,
|
||||
originalname: req.file.originalname,
|
||||
mimetype: req.file.mimetype,
|
||||
ownerType: 'avatar',
|
||||
});
|
||||
|
||||
const merged = {
|
||||
...(user.personal_info || {}),
|
||||
avatar: {
|
||||
url,
|
||||
uuid,
|
||||
name: req.file.originalname,
|
||||
mime_type: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
},
|
||||
};
|
||||
const avatarMeta = await replaceUserAvatar(user, req.file);
|
||||
|
||||
const merged = { ...(user.personal_info || {}), avatar: avatarMeta };
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
||||
@@ -143,6 +124,7 @@ exports.uploadAvatar = async (req, res) => {
|
||||
});
|
||||
return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
if (err.status === 400) return R.error(res, err.message, 400);
|
||||
console.error('[CLIENT] uploadAvatar error:', err);
|
||||
return R.error(res, 'Avatar upload failed.', 500);
|
||||
}
|
||||
@@ -153,16 +135,15 @@ exports.uploadAvatar = async (req, res) => {
|
||||
exports.deleteAvatar = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
const key = user.personal_info?.avatar?.uuid;
|
||||
if (!key) return R.error(res, 'No avatar to remove.', 404);
|
||||
|
||||
await deleteFile(key).catch(() => {});
|
||||
await removeUserAvatar(user);
|
||||
|
||||
const merged = { ...(user.personal_info || {}), avatar: null };
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
return R.success(res, 'Avatar removed.');
|
||||
} catch (err) {
|
||||
if (err.status === 404) return R.error(res, err.message, 404);
|
||||
console.error('[CLIENT] deleteAvatar error:', err);
|
||||
return R.error(res, 'Could not remove avatar.', 500);
|
||||
}
|
||||
|
||||
@@ -30,12 +30,9 @@
|
||||
***********************************************************************************************************************************************************************/
|
||||
"use strict";
|
||||
|
||||
const { Op } = require("sequelize");
|
||||
const R = require("../../utils/response.util");
|
||||
const logActivity = require("../../utils/logActivity.util");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const mdl_Product = require("../../models/courses/products.mdl");
|
||||
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
|
||||
|
||||
const {
|
||||
Unit, Lesson,
|
||||
@@ -64,35 +61,6 @@ function sanitizeQuestions(questions = []) {
|
||||
});
|
||||
}
|
||||
|
||||
// Batch-fetch active product listings + this user's completed/unexpired
|
||||
// purchases for a set of standalone targets (unit or lesson), same shape as
|
||||
// the courses.controller.js equivalent — used by getUnits/getLessons below so
|
||||
// the browse-list Buy button has price data without an N+1 query per row.
|
||||
async function attachProducts(user_id, purchasable_type, ids) {
|
||||
if (!ids.length) return { productById: new Map(), purchasedIds: new Set() };
|
||||
|
||||
const products = await mdl_Product.findAll({
|
||||
where: { purchasable_type, purchasable_id: { [Op.in]: ids }, is_active: true },
|
||||
attributes: ["id", "name", "price", "currency", "access_days", "is_active", "purchasable_id"],
|
||||
});
|
||||
const productById = new Map(products.map((p) => [String(p.purchasable_id), p]));
|
||||
|
||||
const productIds = products.map((p) => p.id);
|
||||
const purchases = productIds.length ? await mdl_CoursePurchase.findAll({
|
||||
where: {
|
||||
user_id, product_id: { [Op.in]: productIds }, status: "completed",
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
attributes: ["product_id"],
|
||||
}) : [];
|
||||
const purchasedProductIds = new Set(purchases.map((p) => String(p.product_id)));
|
||||
const purchasedIds = new Set(
|
||||
products.filter((p) => purchasedProductIds.has(String(p.id))).map((p) => String(p.purchasable_id))
|
||||
);
|
||||
|
||||
return { productById, purchasedIds };
|
||||
}
|
||||
|
||||
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
|
||||
|
||||
// Client-side Units/Lessons browsing shows ALL content, bound to a course or
|
||||
@@ -136,8 +104,6 @@ exports.getUnits = async (req, res) => {
|
||||
coursesByUnit.set(row.unit_id, list);
|
||||
}
|
||||
|
||||
const { productById, purchasedIds } = await attachProducts(req.user.user_id, "unit", unitIds);
|
||||
|
||||
// is_locked mirrors canAccessUnit: a unit with its own subscription or at
|
||||
// least one attached course needs an access check; a fully open standalone
|
||||
// unit (no subscription, no course links) is never locked.
|
||||
@@ -146,16 +112,10 @@ exports.getUnits = async (req, res) => {
|
||||
const is_locked = (row.subscription || Number(row.course_count) > 0)
|
||||
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
|
||||
: false;
|
||||
const product = productById.get(String(row.unit_id)) ?? null;
|
||||
const has_purchased = purchasedIds.has(String(row.unit_id));
|
||||
const purchase_eligible = true;
|
||||
result.push({
|
||||
...row,
|
||||
courses: coursesByUnit.get(row.unit_id) ?? [],
|
||||
is_locked,
|
||||
product,
|
||||
has_purchased,
|
||||
purchase_eligible,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -202,8 +162,6 @@ exports.getLessons = async (req, res) => {
|
||||
coursesByLesson.set(row.lesson_id, list);
|
||||
}
|
||||
|
||||
const { productById, purchasedIds } = await attachProducts(req.user.user_id, "lesson", lessonIds);
|
||||
|
||||
// is_locked mirrors canAccessLesson: a lesson with its own subscription or
|
||||
// at least one attached unit needs an access check; a fully open
|
||||
// standalone lesson (no subscription, no unit links) is never locked.
|
||||
@@ -212,16 +170,10 @@ exports.getLessons = async (req, res) => {
|
||||
const is_locked = (row.subscription || Number(row.unit_count) > 0)
|
||||
? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
|
||||
: false;
|
||||
const product = productById.get(String(row.lesson_id)) ?? null;
|
||||
const has_purchased = purchasedIds.has(String(row.lesson_id));
|
||||
const purchase_eligible = true;
|
||||
result.push({
|
||||
...row,
|
||||
courses: coursesByLesson.get(row.lesson_id) ?? [],
|
||||
is_locked,
|
||||
product,
|
||||
has_purchased,
|
||||
purchase_eligible,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -537,5 +489,3 @@ exports.markStandaloneLessonComplete = async (req, res) => {
|
||||
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
|
||||
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
|
||||
exports.getLessonByUuid = coursesCtrl.getLessonByUuid;
|
||||
exports.getUnitCheckoutInfo = coursesCtrl.getUnitCheckoutInfo;
|
||||
exports.getLessonCheckoutInfo = coursesCtrl.getLessonCheckoutInfo;
|
||||
|
||||
Reference in New Issue
Block a user