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 {
|
return {
|
||||||
...entry,
|
...entry,
|
||||||
user: {
|
user: {
|
||||||
email: u?.email ?? null,
|
email: u?.email ?? null,
|
||||||
full_name: u?.personal_info?.name?.full_name ?? null,
|
full_name: u?.personal_info?.name?.full_name ?? null,
|
||||||
avatar_url: avatar?.url ?? null,
|
avatar_stream_token: avatar?.stream_token ?? null,
|
||||||
},
|
},
|
||||||
units_total,
|
units_total,
|
||||||
lessons_total,
|
lessons_total,
|
||||||
|
|||||||
@@ -2614,13 +2614,13 @@ exports.syncInstructors = async (req, res) => {
|
|||||||
// ─── COMPLETIONS HELPERS ──────────────────────────────────────────────────────
|
// ─── COMPLETIONS HELPERS ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function extractUserInfo(user) {
|
async function extractUserInfo(user) {
|
||||||
if (!user) return { full_name: null, email: null, avatar_url: null, deleted: false };
|
if (!user) return { full_name: null, email: null, avatar_stream_token: null, deleted: false };
|
||||||
const avatar = await resolveAvatarUrl(user.personal_info?.avatar);
|
const avatar = await resolveAvatarUrl(user.personal_info?.avatar);
|
||||||
return {
|
return {
|
||||||
full_name: user.personal_info?.name?.full_name ?? null,
|
full_name: user.personal_info?.name?.full_name ?? null,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
avatar_url: avatar?.url ?? null,
|
avatar_stream_token: avatar?.stream_token ?? null,
|
||||||
deleted: !!user.deletedAt,
|
deleted: !!user.deletedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2629,12 +2629,12 @@ async function groupByUser(attempts) {
|
|||||||
for (const a of attempts) {
|
for (const a of attempts) {
|
||||||
const uid = String(a.user_id);
|
const uid = String(a.user_id);
|
||||||
if (!map.has(uid)) {
|
if (!map.has(uid)) {
|
||||||
const { full_name, email, avatar_url, deleted } = await extractUserInfo(a.user);
|
const { full_name, email, avatar_stream_token, deleted } = await extractUserInfo(a.user);
|
||||||
map.set(uid, {
|
map.set(uid, {
|
||||||
user_id: a.user_id,
|
user_id: a.user_id,
|
||||||
full_name,
|
full_name,
|
||||||
email,
|
email,
|
||||||
avatar_url,
|
avatar_stream_token,
|
||||||
deleted,
|
deleted,
|
||||||
attempt_count: 0,
|
attempt_count: 0,
|
||||||
best_score: 0,
|
best_score: 0,
|
||||||
@@ -2757,7 +2757,7 @@ exports.getAssessmentSessions = async (req, res) => {
|
|||||||
|
|
||||||
const rows = await Promise.all(sessions.map(async (s) => {
|
const rows = await Promise.all(sessions.map(async (s) => {
|
||||||
const j = s.toJSON();
|
const j = s.toJSON();
|
||||||
const { full_name, email, avatar_url, deleted } = 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
|
const time_spent_seconds = j.status !== 'in_progress' && j.started_at
|
||||||
? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000)
|
? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000)
|
||||||
: null;
|
: null;
|
||||||
@@ -2766,7 +2766,7 @@ exports.getAssessmentSessions = async (req, res) => {
|
|||||||
user_id: j.user_id,
|
user_id: j.user_id,
|
||||||
full_name,
|
full_name,
|
||||||
email,
|
email,
|
||||||
avatar_url,
|
avatar_stream_token,
|
||||||
deleted,
|
deleted,
|
||||||
status: j.status,
|
status: j.status,
|
||||||
started_at: j.started_at,
|
started_at: j.started_at,
|
||||||
|
|||||||
@@ -79,21 +79,11 @@ function makeProductHandlers(purchasable_type, paramName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const courseProductHandlers = makeProductHandlers('course', 'courseId');
|
const courseProductHandlers = makeProductHandlers('course', 'courseId');
|
||||||
const unitProductHandlers = makeProductHandlers('unit', 'unitId');
|
|
||||||
const lessonProductHandlers = makeProductHandlers('lesson', 'lessonId');
|
|
||||||
|
|
||||||
exports.getCourseProduct = courseProductHandlers.get;
|
exports.getCourseProduct = courseProductHandlers.get;
|
||||||
exports.upsertCourseProduct = courseProductHandlers.upsert;
|
exports.upsertCourseProduct = courseProductHandlers.upsert;
|
||||||
exports.removeCourseProduct = courseProductHandlers.remove;
|
exports.removeCourseProduct = courseProductHandlers.remove;
|
||||||
|
|
||||||
exports.getUnitProduct = unitProductHandlers.get;
|
|
||||||
exports.upsertUnitProduct = unitProductHandlers.upsert;
|
|
||||||
exports.removeUnitProduct = unitProductHandlers.remove;
|
|
||||||
|
|
||||||
exports.getLessonProduct = lessonProductHandlers.get;
|
|
||||||
exports.upsertLessonProduct = lessonProductHandlers.upsert;
|
|
||||||
exports.removeLessonProduct = lessonProductHandlers.remove;
|
|
||||||
|
|
||||||
// ─── CATEGORIES (per course) ──────────────────────────────────────────────────
|
// ─── CATEGORIES (per course) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getCourseCategories = async (req, res) => {
|
exports.getCourseCategories = async (req, res) => {
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const mdl_Users = require('../../models/users/users.mdl');
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { uploadFile, deleteFile } = require('../../services/s3.service');
|
const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service');
|
||||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||||
|
|
||||||
// ─── GET own profile ───────────────────────────────────────────────────────────
|
// ─── GET own profile ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -69,28 +69,9 @@ exports.uploadAvatar = async (req, res) => {
|
|||||||
|
|
||||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||||
|
|
||||||
// Remove old avatar from S3 before replacing
|
const avatarMeta = await replaceUserAvatar(user, req.file);
|
||||||
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 merged = { ...(user.personal_info || {}), avatar: avatarMeta };
|
||||||
await user.update({ personal_info: merged });
|
await user.update({ personal_info: merged });
|
||||||
|
|
||||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
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));
|
return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.status === 400) return R.error(res, err.message, 400);
|
||||||
console.error('[ADMIN] uploadAvatar error:', err);
|
console.error('[ADMIN] uploadAvatar error:', err);
|
||||||
return R.error(res, 'Avatar upload failed.', 500);
|
return R.error(res, 'Avatar upload failed.', 500);
|
||||||
}
|
}
|
||||||
@@ -108,16 +90,15 @@ exports.uploadAvatar = async (req, res) => {
|
|||||||
exports.deleteAvatar = async (req, res) => {
|
exports.deleteAvatar = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
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 };
|
const merged = { ...(user.personal_info || {}), avatar: null };
|
||||||
await user.update({ personal_info: merged });
|
await user.update({ personal_info: merged });
|
||||||
|
|
||||||
return R.success(res, 'Avatar removed.');
|
return R.success(res, 'Avatar removed.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.status === 404) return R.error(res, err.message, 404);
|
||||||
console.error('[ADMIN] deleteAvatar error:', err);
|
console.error('[ADMIN] deleteAvatar error:', err);
|
||||||
return R.error(res, 'Could not remove avatar.', 500);
|
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 { getFieldValues } = require('../../utils/fieldValues.util');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const { snapshotPlanGrants } = require('../../services/tierGrants.service');
|
const { snapshotPlanGrants } = require('../../services/tierGrants.service');
|
||||||
const { revokePlanSubscriberAccess } = require('../../services/planAccess.service');
|
const { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk } = require('../../services/planAccess.service');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
excludeAttributes: plansExclude,
|
excludeAttributes: plansExclude,
|
||||||
@@ -244,16 +244,14 @@ exports.bulkArchivePlans = async (req, res) => {
|
|||||||
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
|
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
|
||||||
|
|
||||||
// Archiving always force-revokes current subscribers' access (no refund) —
|
// Archiving always force-revokes current subscribers' access (no refund) —
|
||||||
// each plan fires its own tier_plan_access_revoked (needs each plan's own
|
// one batched call across all selected plans (each still fires its own
|
||||||
// label), not the old batched "access unaffected" tier_plan_archived notice.
|
// 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;
|
let revoked_user_count = 0;
|
||||||
for (const p of activePlans) {
|
try {
|
||||||
try {
|
({ revoked_user_count } = await revokePlanSubscriberAccessBulk(activePlans, req.user?.user_id ?? null));
|
||||||
const { revoked_user_count: c } = await revokePlanSubscriberAccess(p, req.user?.user_id ?? null);
|
} catch (revokeErr) {
|
||||||
revoked_user_count += c;
|
console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr);
|
||||||
} 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 } });
|
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);
|
const archivedIds = archivedPlans.map((p) => p.plan_id);
|
||||||
|
|
||||||
// Same reasoning as the single-delete path above: revoke any remaining
|
// Same reasoning as the single-delete path above: revoke any remaining
|
||||||
// active subscribers per plan (each needs its own label for the
|
// active subscribers (each plan still gets its own label on the
|
||||||
// notification/email) before the records are gone for good.
|
// notification/email) before the records are gone for good — batched in
|
||||||
let revoked_user_count = 0;
|
// one call across all selected plans instead of one call per plan.
|
||||||
for (const p of archivedPlans) {
|
const { revoked_user_count } = await revokePlanSubscriberAccessBulk(archivedPlans, req.user?.user_id ?? null);
|
||||||
const { revoked_user_count: c } = await revokePlanSubscriberAccess(p, req.user?.user_id ?? null);
|
|
||||||
revoked_user_count += c;
|
|
||||||
}
|
|
||||||
|
|
||||||
// payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted
|
// 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
|
// 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 info = r.user?.personal_info;
|
||||||
const avatar = await resolveAvatarUrl(info?.avatar);
|
const avatar = await resolveAvatarUrl(info?.avatar);
|
||||||
return {
|
return {
|
||||||
activity_id: r.activity_id,
|
activity_id: r.activity_id,
|
||||||
user_id: r.user_id,
|
user_id: r.user_id,
|
||||||
email: r.user?.email ?? null,
|
email: r.user?.email ?? null,
|
||||||
full_name: info?.name?.full_name ?? null,
|
full_name: info?.name?.full_name ?? null,
|
||||||
avatar_url: avatar?.url ?? null,
|
avatar_stream_token: avatar?.stream_token ?? null,
|
||||||
acc_type: r.user?.acc_type ?? null,
|
acc_type: r.user?.acc_type ?? null,
|
||||||
action: r.action,
|
action: r.action,
|
||||||
entity_type: r.entity_type,
|
entity_type: r.entity_type,
|
||||||
entity_id: r.entity_id,
|
entity_id: r.entity_id,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const R = require('../../utils/response.util');
|
|||||||
const { paginate } = require('../../utils/paginate.util');
|
const { paginate } = require('../../utils/paginate.util');
|
||||||
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
||||||
const { getFieldValues } = require("../../utils/fieldValues.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');
|
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);
|
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) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][GET USER]', err);
|
console.error('[ADMIN][GET USER]', err);
|
||||||
return R.error(res, 'Could not retrieve user.', 500);
|
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'] });
|
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] });
|
||||||
if (unit?.subscription && await hasItemGrant(user_id, 'unit', unit_id)) return true;
|
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
|
// Only links to PUBLISHED courses count as a real course dependency — a unit
|
||||||
// whose only link is to a draft/unpublished course behaves as if it had no
|
// whose only link is to a draft/unpublished course behaves as if it had no
|
||||||
// course link at all (falls through to the free/standalone branch below),
|
// course link at all (falls through to the free/standalone branch below),
|
||||||
@@ -182,8 +180,6 @@ async function canAccessLesson(user_id, lesson_id) {
|
|||||||
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
|
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
|
||||||
if (lesson?.subscription && await hasItemGrant(user_id, 'lesson', lesson_id)) return true;
|
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'] });
|
const unitLinks = await UnitLesson.findAll({ where: { lesson_id }, attributes: ['unit_id'] });
|
||||||
if (!unitLinks.length) return !lesson?.subscription || lesson.subscription === 'free';
|
if (!unitLinks.length) return !lesson?.subscription || lesson.subscription === 'free';
|
||||||
for (const link of unitLinks) {
|
for (const link of unitLinks) {
|
||||||
@@ -1311,12 +1307,11 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
|||||||
|
|
||||||
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
|
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
|
||||||
const first = unit.courses?.[0] ?? null;
|
const first = unit.courses?.[0] ?? null;
|
||||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
|
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
status: "error",
|
status: "error",
|
||||||
message: "You do not have access to this unit.",
|
message: "You do not have access to this unit.",
|
||||||
course: first ? { title: first.title, subscription: first.subscription } : null,
|
course: first ? { title: first.title, subscription: first.subscription } : null,
|
||||||
item: { uuid: unit.uuid, subscription: unit.subscription, product, has_purchased, 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)) {
|
if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) {
|
||||||
const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null;
|
const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null;
|
||||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
|
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
status: "error",
|
status: "error",
|
||||||
message: "You do not have access to this lesson.",
|
message: "You do not have access to this lesson.",
|
||||||
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
|
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
|
||||||
item: { uuid: lesson.uuid, subscription: lesson.subscription, product, has_purchased, purchase_eligible },
|
item: { uuid: lesson.uuid, subscription: lesson.subscription },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1491,18 +1485,16 @@ exports.getLessonByUuid = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── CHECKOUT INFO (course/unit/lesson) ───────────────────────────────────────
|
// ─── CHECKOUT INFO (course) ────────────────────────────────────────────────
|
||||||
// Deliberately does NOT hard-403 on locked content like getCourse/
|
// Deliberately does NOT hard-403 on locked content like getCourse does — a
|
||||||
// getUnitByUuid/getLessonByUuid do — a locked-and-unpurchased item is exactly
|
// locked-and-unpurchased course is exactly who needs to land on this page and
|
||||||
// who needs to land on this page and see title/description/product, so it
|
// see title/description/product, so it can't gate on the same canAccess*()
|
||||||
// can't gate on the same canAccess*() check those content-serving routes use.
|
// check those content-serving routes use. Auth-only; content stays fully
|
||||||
// Auth-only; content stays fully protected behind the routes above.
|
// 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) {
|
async function buildCheckoutInfo(user_id, purchasable_type, record) {
|
||||||
const product = await mdl_Product.findOne({
|
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"],
|
attributes: ["id", "name", "price", "currency", "access_days"],
|
||||||
});
|
});
|
||||||
const hasPurchase = product && await mdl_CoursePurchase.findOne({
|
const hasPurchase = product && await mdl_CoursePurchase.findOne({
|
||||||
@@ -1528,30 +1520,4 @@ exports.getCourseCheckoutInfo = async (req, res) => {
|
|||||||
console.error("[CLIENT][COURSES][CHECKOUT INFO]", err);
|
console.error("[CLIENT][COURSES][CHECKOUT INFO]", err);
|
||||||
return R.error(res, "Could not retrieve checkout info.", 500);
|
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 mdl_Achievements = require('../../models/users/achievements.mdl');
|
||||||
const trustedDevice = require('../../services/trustedDevice.service');
|
const trustedDevice = require('../../services/trustedDevice.service');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { uploadFile, deleteFile } = require('../../services/s3.service');
|
const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service');
|
||||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||||
|
|
||||||
// ─── GET own profile ───────────────────────────────────────────────────────────
|
// ─── GET own profile ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -114,28 +114,9 @@ exports.uploadAvatar = async (req, res) => {
|
|||||||
|
|
||||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||||
|
|
||||||
// Remove old avatar from S3 before replacing
|
const avatarMeta = await replaceUserAvatar(user, req.file);
|
||||||
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 merged = { ...(user.personal_info || {}), avatar: avatarMeta };
|
||||||
await user.update({ personal_info: merged });
|
await user.update({ personal_info: merged });
|
||||||
|
|
||||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
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));
|
return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.status === 400) return R.error(res, err.message, 400);
|
||||||
console.error('[CLIENT] uploadAvatar error:', err);
|
console.error('[CLIENT] uploadAvatar error:', err);
|
||||||
return R.error(res, 'Avatar upload failed.', 500);
|
return R.error(res, 'Avatar upload failed.', 500);
|
||||||
}
|
}
|
||||||
@@ -153,16 +135,15 @@ exports.uploadAvatar = async (req, res) => {
|
|||||||
exports.deleteAvatar = async (req, res) => {
|
exports.deleteAvatar = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
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 };
|
const merged = { ...(user.personal_info || {}), avatar: null };
|
||||||
await user.update({ personal_info: merged });
|
await user.update({ personal_info: merged });
|
||||||
|
|
||||||
return R.success(res, 'Avatar removed.');
|
return R.success(res, 'Avatar removed.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.status === 404) return R.error(res, err.message, 404);
|
||||||
console.error('[CLIENT] deleteAvatar error:', err);
|
console.error('[CLIENT] deleteAvatar error:', err);
|
||||||
return R.error(res, 'Could not remove avatar.', 500);
|
return R.error(res, 'Could not remove avatar.', 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,9 @@
|
|||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const { Op } = require("sequelize");
|
|
||||||
const R = require("../../utils/response.util");
|
const R = require("../../utils/response.util");
|
||||||
const logActivity = require("../../utils/logActivity.util");
|
const logActivity = require("../../utils/logActivity.util");
|
||||||
const sequelize = require("../../config/db.config");
|
const sequelize = require("../../config/db.config");
|
||||||
const mdl_Product = require("../../models/courses/products.mdl");
|
|
||||||
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Unit, Lesson,
|
Unit, Lesson,
|
||||||
@@ -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) ──────────────────────────────────────────────
|
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
|
||||||
|
|
||||||
// Client-side Units/Lessons browsing shows ALL content, bound to a course or
|
// Client-side Units/Lessons browsing shows ALL content, bound to a course or
|
||||||
@@ -136,8 +104,6 @@ exports.getUnits = async (req, res) => {
|
|||||||
coursesByUnit.set(row.unit_id, list);
|
coursesByUnit.set(row.unit_id, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { productById, purchasedIds } = await attachProducts(req.user.user_id, "unit", unitIds);
|
|
||||||
|
|
||||||
// is_locked mirrors canAccessUnit: a unit with its own subscription or at
|
// is_locked mirrors canAccessUnit: a unit with its own subscription or at
|
||||||
// least one attached course needs an access check; a fully open standalone
|
// least one attached course needs an access check; a fully open standalone
|
||||||
// unit (no subscription, no course links) is never locked.
|
// unit (no subscription, no course links) is never locked.
|
||||||
@@ -146,16 +112,10 @@ exports.getUnits = async (req, res) => {
|
|||||||
const is_locked = (row.subscription || Number(row.course_count) > 0)
|
const is_locked = (row.subscription || Number(row.course_count) > 0)
|
||||||
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
|
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
|
||||||
: false;
|
: false;
|
||||||
const product = productById.get(String(row.unit_id)) ?? null;
|
|
||||||
const has_purchased = purchasedIds.has(String(row.unit_id));
|
|
||||||
const purchase_eligible = true;
|
|
||||||
result.push({
|
result.push({
|
||||||
...row,
|
...row,
|
||||||
courses: coursesByUnit.get(row.unit_id) ?? [],
|
courses: coursesByUnit.get(row.unit_id) ?? [],
|
||||||
is_locked,
|
is_locked,
|
||||||
product,
|
|
||||||
has_purchased,
|
|
||||||
purchase_eligible,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +162,6 @@ exports.getLessons = async (req, res) => {
|
|||||||
coursesByLesson.set(row.lesson_id, list);
|
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
|
// is_locked mirrors canAccessLesson: a lesson with its own subscription or
|
||||||
// at least one attached unit needs an access check; a fully open
|
// at least one attached unit needs an access check; a fully open
|
||||||
// standalone lesson (no subscription, no unit links) is never locked.
|
// 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)
|
const is_locked = (row.subscription || Number(row.unit_count) > 0)
|
||||||
? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
|
? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
|
||||||
: false;
|
: false;
|
||||||
const product = productById.get(String(row.lesson_id)) ?? null;
|
|
||||||
const has_purchased = purchasedIds.has(String(row.lesson_id));
|
|
||||||
const purchase_eligible = true;
|
|
||||||
result.push({
|
result.push({
|
||||||
...row,
|
...row,
|
||||||
courses: coursesByLesson.get(row.lesson_id) ?? [],
|
courses: coursesByLesson.get(row.lesson_id) ?? [],
|
||||||
is_locked,
|
is_locked,
|
||||||
product,
|
|
||||||
has_purchased,
|
|
||||||
purchase_eligible,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,5 +489,3 @@ exports.markStandaloneLessonComplete = async (req, res) => {
|
|||||||
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
|
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
|
||||||
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
|
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
|
||||||
exports.getLessonByUuid = coursesCtrl.getLessonByUuid;
|
exports.getLessonByUuid = coursesCtrl.getLessonByUuid;
|
||||||
exports.getUnitCheckoutInfo = coursesCtrl.getUnitCheckoutInfo;
|
|
||||||
exports.getLessonCheckoutInfo = coursesCtrl.getLessonCheckoutInfo;
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
const { DataTypes } = require('sequelize');
|
const { DataTypes } = require('sequelize');
|
||||||
const sequelize = require('../../config/db.config');
|
const sequelize = require('../../config/db.config');
|
||||||
const { Course } = require('./courses.mdl');
|
const { Course } = require('./courses.mdl');
|
||||||
const Unit = require('./units.mdl');
|
|
||||||
const Lesson = require('./lessons.mdl');
|
|
||||||
|
|
||||||
// Polymorphic target — a product is sold against exactly one Course, Unit, or
|
// Polymorphic target — a product is sold against a Course (purchasable_type +
|
||||||
// Lesson (purchasable_type + purchasable_id), not just courses. See
|
// purchasable_id). Unit/Lesson individual purchase was removed; the schema
|
||||||
|
// stays polymorphic (purchasable_type/purchasable_id, not a course_id FK)
|
||||||
|
// since existing rows and course_purchases still key off it. See
|
||||||
// utils/purchasable.util.js for the type -> model/checkout-path resolver used
|
// utils/purchasable.util.js for the type -> model/checkout-path resolver used
|
||||||
// by every consumer (access checks, admin CRUD, checkout).
|
// by every consumer (access checks, admin CRUD, checkout).
|
||||||
//
|
//
|
||||||
@@ -30,12 +30,9 @@ const mdl_Product = sequelize.define('Product', {
|
|||||||
paranoid: true,
|
paranoid: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scoped hasOne per target type — Sequelize automatically adds the matching
|
// Scoped hasOne — Sequelize automatically adds the matching purchasable_type
|
||||||
// purchasable_type filter to the join, so existing
|
// filter to the join, so existing `include: [{ model: mdl_Product, as:
|
||||||
// `include: [{ model: mdl_Product, as: 'product' }]` call sites on Course
|
// 'product' }]` call sites on Course keep working unchanged.
|
||||||
// keep working unchanged.
|
|
||||||
Course.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'course' }, as: 'product' });
|
Course.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'course' }, as: 'product' });
|
||||||
Unit.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'unit' }, as: 'product' });
|
|
||||||
Lesson.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'lesson' }, as: 'product' });
|
|
||||||
|
|
||||||
module.exports = mdl_Product;
|
module.exports = mdl_Product;
|
||||||
|
|||||||
Generated
+610
-5
@@ -38,6 +38,7 @@
|
|||||||
"rate-limit-redis": "^4.0.0",
|
"rate-limit-redis": "^4.0.0",
|
||||||
"redis": "^4.6.7",
|
"redis": "^4.6.7",
|
||||||
"sequelize": "^6.32.1",
|
"sequelize": "^6.32.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"ua-parser-js": "^2.0.10",
|
"ua-parser-js": "^2.0.10",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
@@ -992,10 +993,8 @@
|
|||||||
"version": "1.11.2",
|
"version": "1.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
@@ -1012,6 +1011,554 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.11.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -3304,6 +3851,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-newline": {
|
"node_modules/detect-newline": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
||||||
@@ -6827,9 +7383,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -7016,6 +7572,55 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.1.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.8.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.35.3",
|
||||||
|
"@img/sharp-darwin-x64": "0.35.3",
|
||||||
|
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||||
|
"@img/sharp-linux-arm": "0.35.3",
|
||||||
|
"@img/sharp-linux-arm64": "0.35.3",
|
||||||
|
"@img/sharp-linux-ppc64": "0.35.3",
|
||||||
|
"@img/sharp-linux-riscv64": "0.35.3",
|
||||||
|
"@img/sharp-linux-s390x": "0.35.3",
|
||||||
|
"@img/sharp-linux-x64": "0.35.3",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||||
|
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||||
|
"@img/sharp-win32-arm64": "0.35.3",
|
||||||
|
"@img/sharp-win32-ia32": "0.35.3",
|
||||||
|
"@img/sharp-win32-x64": "0.35.3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"rate-limit-redis": "^4.0.0",
|
"rate-limit-redis": "^4.0.0",
|
||||||
"redis": "^4.6.7",
|
"redis": "^4.6.7",
|
||||||
"sequelize": "^6.32.1",
|
"sequelize": "^6.32.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"ua-parser-js": "^2.0.10",
|
"ua-parser-js": "^2.0.10",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,16 +7,6 @@ router.get ('/courses/:courseId/product', ctrl.getCourseProduct);
|
|||||||
router.put ('/courses/:courseId/product', ctrl.upsertCourseProduct);
|
router.put ('/courses/:courseId/product', ctrl.upsertCourseProduct);
|
||||||
router.delete('/courses/:courseId/product', ctrl.removeCourseProduct);
|
router.delete('/courses/:courseId/product', ctrl.removeCourseProduct);
|
||||||
|
|
||||||
// Product per standalone unit
|
|
||||||
router.get ('/units/:unitId/product', ctrl.getUnitProduct);
|
|
||||||
router.put ('/units/:unitId/product', ctrl.upsertUnitProduct);
|
|
||||||
router.delete('/units/:unitId/product', ctrl.removeUnitProduct);
|
|
||||||
|
|
||||||
// Product per standalone lesson
|
|
||||||
router.get ('/lessons/:lessonId/product', ctrl.getLessonProduct);
|
|
||||||
router.put ('/lessons/:lessonId/product', ctrl.upsertLessonProduct);
|
|
||||||
router.delete('/lessons/:lessonId/product', ctrl.removeLessonProduct);
|
|
||||||
|
|
||||||
// Categories per course
|
// Categories per course
|
||||||
router.get ('/courses/:courseId/categories', ctrl.getCourseCategories);
|
router.get ('/courses/:courseId/categories', ctrl.getCourseCategories);
|
||||||
router.post ('/courses/:courseId/categories', ctrl.syncCourseCategories);
|
router.post ('/courses/:courseId/categories', ctrl.syncCourseCategories);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const router = express.Router();
|
|||||||
const ctrl = require('../../controllers/client/units.controller');
|
const ctrl = require('../../controllers/client/units.controller');
|
||||||
|
|
||||||
router.get('/', ctrl.getLessons);
|
router.get('/', ctrl.getLessons);
|
||||||
router.get('/:uuid/checkout-info', ctrl.getLessonCheckoutInfo);
|
|
||||||
router.get('/:uuid', ctrl.getLessonByUuid);
|
router.get('/:uuid', ctrl.getLessonByUuid);
|
||||||
router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress);
|
router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress);
|
||||||
router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress);
|
router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const ctrl = require('../../controllers/client/units.controller');
|
|||||||
router.get('/', ctrl.getUnits);
|
router.get('/', ctrl.getUnits);
|
||||||
router.get('/:uuid/lessons', ctrl.getLessonsByUnitUuid);
|
router.get('/:uuid/lessons', ctrl.getLessonsByUnitUuid);
|
||||||
router.get('/:uuid/quiz', ctrl.getUnitQuiz);
|
router.get('/:uuid/quiz', ctrl.getUnitQuiz);
|
||||||
router.get('/:uuid/checkout-info', ctrl.getUnitCheckoutInfo);
|
|
||||||
router.patch('/:uuid/quiz/:quizId/draft', ctrl.saveUnitQuizDraft);
|
router.patch('/:uuid/quiz/:quizId/draft', ctrl.saveUnitQuizDraft);
|
||||||
router.post('/:uuid/quiz/:quizId/submit', ctrl.submitUnitQuiz);
|
router.post('/:uuid/quiz/:quizId/submit', ctrl.submitUnitQuiz);
|
||||||
router.get('/:uuid', ctrl.getUnitByUuid);
|
router.get('/:uuid', ctrl.getUnitByUuid);
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
|
// Local-dev-only: stop the process on wake from laptop sleep/idle instead of
|
||||||
|
// letting node-cron dump a "missed execution" warning per elapsed tick. Must
|
||||||
|
// start ticking as early as possible so it wins the race against node-cron's
|
||||||
|
// own heartbeat once cron jobs are registered below. No-ops in production.
|
||||||
|
require('./utils/suspendGuard.util').startSuspendGuard();
|
||||||
|
|
||||||
// Force IPv4-only outbound connections. Hosts that resolve AAAA records but
|
// Force IPv4-only outbound connections. Hosts that resolve AAAA records but
|
||||||
// have no working IPv6 route (e.g. to Google's OAuth endpoints) hit ENETUNREACH
|
// have no working IPv6 route (e.g. to Google's OAuth endpoints) hit ENETUNREACH
|
||||||
// on the v6 attempt — and Node's dual-stack "Happy Eyeballs" connector
|
// on the v6 attempt — and Node's dual-stack "Happy Eyeballs" connector
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: avatar.service.js
|
||||||
|
* Type of Program: Service
|
||||||
|
* Description: Shared avatar processing/orchestration for admin + client self-profile.
|
||||||
|
* Server-side authoritative resize — every avatar is normalized to a fixed
|
||||||
|
* 200x200 JPEG regardless of what the client sends, so browser-side cropping
|
||||||
|
* (see AvatarUploadDialog.jsx) is a UX convenience, not the enforcement point.
|
||||||
|
*
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Aug. 10, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const { uploadFile, deleteFile } = require('./s3.service');
|
||||||
|
|
||||||
|
const AVATAR_SIZE = 200;
|
||||||
|
const JPEG_QUALITY = 90;
|
||||||
|
|
||||||
|
// ─── resizeAvatarBuffer ─────────────────────────────────────────────────────────
|
||||||
|
// Normalizes any accepted input (JPEG/PNG/WebP/GIF) into a fixed 200x200 JPEG.
|
||||||
|
// GIF animation and PNG transparency are intentionally dropped — avatars render
|
||||||
|
// in an opaque round mask, so a single static frame is all that's ever shown.
|
||||||
|
|
||||||
|
async function resizeAvatarBuffer(buffer) {
|
||||||
|
try {
|
||||||
|
return await sharp(buffer)
|
||||||
|
.rotate() // respect EXIF orientation before crop
|
||||||
|
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'centre' })
|
||||||
|
.jpeg({ quality: JPEG_QUALITY })
|
||||||
|
.toBuffer();
|
||||||
|
} catch (err) {
|
||||||
|
throw Object.assign(new Error('Could not process the uploaded image.'), { status: 400, cause: err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── replaceUserAvatar ──────────────────────────────────────────────────────────
|
||||||
|
// Deletes the old S3 object (if any), resizes the new upload, stores it, and
|
||||||
|
// returns the avatar metadata object. Does not persist to the user row —
|
||||||
|
// callers own that so they can merge it into personal_info their own way.
|
||||||
|
|
||||||
|
async function replaceUserAvatar(user, file) {
|
||||||
|
const oldKey = user.personal_info?.avatar?.uuid;
|
||||||
|
if (oldKey) await deleteFile(oldKey).catch(() => {});
|
||||||
|
|
||||||
|
const resized = await resizeAvatarBuffer(file.buffer);
|
||||||
|
|
||||||
|
const { url, uuid } = await uploadFile({
|
||||||
|
buffer: resized,
|
||||||
|
originalname: 'avatar.jpg',
|
||||||
|
mimetype: 'image/jpeg',
|
||||||
|
ownerType: 'avatar',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
uuid,
|
||||||
|
name: file.originalname,
|
||||||
|
mime_type: 'image/jpeg',
|
||||||
|
size: resized.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── removeUserAvatar ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function removeUserAvatar(user) {
|
||||||
|
const key = user.personal_info?.avatar?.uuid;
|
||||||
|
if (!key) throw Object.assign(new Error('No avatar to remove.'), { status: 404 });
|
||||||
|
|
||||||
|
await deleteFile(key).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resizeAvatarBuffer, replaceUserAvatar, removeUserAvatar };
|
||||||
@@ -69,21 +69,31 @@ function resolveIp(req) {
|
|||||||
return normalizeIp(raw);
|
return normalizeIp(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
function signToken(asset, userId, ip) {
|
// ─── signMediaToken ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Low-level JWT signer shared by every media-token caller (asset previews here,
|
||||||
|
// avatar resolution in utils/resolveAvatar.util.js) so the secret-resolution +
|
||||||
|
// payload shape only lives in one place. `asset_id`/`user_id`/`ip` are optional —
|
||||||
|
// omitting `ip` means the stream endpoint's IP-pin check is skipped for that token.
|
||||||
|
function signMediaToken({ asset_id, storage_key, file_type, mime_type, user_id, ip, expiresIn = TOKEN_TTL_SEC }) {
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{
|
{ asset_id, user_id, storage_key, file_type, mime_type, ip },
|
||||||
asset_id: asset.asset_id,
|
|
||||||
user_id: userId,
|
|
||||||
storage_key: asset.storage_key,
|
|
||||||
file_type: asset.file_type,
|
|
||||||
mime_type: asset.mime_type,
|
|
||||||
ip,
|
|
||||||
},
|
|
||||||
MEDIA_SECRET,
|
MEDIA_SECRET,
|
||||||
{ expiresIn: TOKEN_TTL_SEC }
|
{ expiresIn }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function signToken(asset, userId, ip) {
|
||||||
|
return signMediaToken({
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
storage_key: asset.storage_key,
|
||||||
|
file_type: asset.file_type,
|
||||||
|
mime_type: asset.mime_type,
|
||||||
|
user_id: userId,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── issueForAsset ─────────────────────────────────────────────────────────────
|
// ─── issueForAsset ─────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Returns { token, thumbnail_url } for an S3 asset, minting + caching on first
|
// Returns { token, thumbnail_url } for an S3 asset, minting + caching on first
|
||||||
@@ -114,4 +124,5 @@ module.exports = {
|
|||||||
SUPPORTED_TYPES,
|
SUPPORTED_TYPES,
|
||||||
resolveIp,
|
resolveIp,
|
||||||
issueForAsset,
|
issueForAsset,
|
||||||
|
signMediaToken,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,14 +20,22 @@ const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
|
|||||||
const { sendEmail } = require('./email.service');
|
const { sendEmail } = require('./email.service');
|
||||||
const { fmtDate } = require('../utils/datetime.util');
|
const { fmtDate } = require('../utils/datetime.util');
|
||||||
|
|
||||||
// Revokes every active user_tiers row tied to `plan`, replicating revokeTier's
|
// Revokes every active user_tiers row tied to any of `plans`, replicating
|
||||||
// per-user "auto-downgrade to Free if no other active tier remains" rule
|
// revokeTier's per-user "auto-downgrade to Free if no other active tier
|
||||||
// (controllers/admin/tiers.controller.js) — a user can hold more than one
|
// remains" rule (controllers/admin/tiers.controller.js) — a user can hold
|
||||||
// concurrently-active plan, so this can't be a blanket status update.
|
// more than one concurrently-active plan, so this can't be a blanket status
|
||||||
async function revokePlanSubscriberAccess(plan, revokedByUserId) {
|
// update. Batched into flat, count-independent queries (no per-user or
|
||||||
|
// per-plan loop hitting the DB) so this scales to any number of affected
|
||||||
|
// plans/subscribers in a fixed number of round trips.
|
||||||
|
async function revokePlanSubscriberAccessBulk(plans, revokedByUserId) {
|
||||||
|
if (!plans.length) return { revoked_user_count: 0 };
|
||||||
|
|
||||||
|
const planIds = plans.map((p) => p.plan_id);
|
||||||
|
const labelByPlanId = new Map(plans.map((p) => [String(p.plan_id), p.label]));
|
||||||
|
|
||||||
const activeRows = await mdl_UserTiers.findAll({
|
const activeRows = await mdl_UserTiers.findAll({
|
||||||
where: { plan_id: plan.plan_id, status: 'active' },
|
where: { plan_id: planIds, status: 'active' },
|
||||||
attributes: ['tier_id', 'user_id'],
|
attributes: ['tier_id', 'user_id', 'plan_id'],
|
||||||
});
|
});
|
||||||
if (!activeRows.length) return { revoked_user_count: 0 };
|
if (!activeRows.length) return { revoked_user_count: 0 };
|
||||||
|
|
||||||
@@ -40,10 +48,19 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
|
|||||||
{ where: { tier_id: tierIds } },
|
{ where: { tier_id: tierIds } },
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const user_id of userIds) {
|
// One grouped query replaces a per-user COUNT: finds everyone who still
|
||||||
const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } });
|
// holds another active tier after the revoke above.
|
||||||
if (remainingActive === 0) {
|
const stillActiveRows = await mdl_UserTiers.findAll({
|
||||||
await mdl_UserTiers.create({
|
where: { user_id: userIds, status: 'active' },
|
||||||
|
attributes: ['user_id'],
|
||||||
|
group: ['user_id'],
|
||||||
|
});
|
||||||
|
const stillActiveUserIds = new Set(stillActiveRows.map((r) => String(r.user_id)));
|
||||||
|
const usersToDowngrade = userIds.filter((user_id) => !stillActiveUserIds.has(user_id));
|
||||||
|
|
||||||
|
if (usersToDowngrade.length) {
|
||||||
|
await mdl_UserTiers.bulkCreate(
|
||||||
|
usersToDowngrade.map((user_id) => ({
|
||||||
user_id,
|
user_id,
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -51,16 +68,23 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
|
|||||||
expires_at: null,
|
expires_at: null,
|
||||||
granted_by: revokedByUserId,
|
granted_by: revokedByUserId,
|
||||||
notes: 'Auto-downgrade after plan access was force-revoked.',
|
notes: 'Auto-downgrade after plan access was force-revoked.',
|
||||||
});
|
})),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One row per (user, plan) relationship revoked — a user in two of the
|
||||||
|
// selected plans gets two notices/emails, one per plan label.
|
||||||
|
const revokedPairs = [...new Map(activeRows.map((r) => [`${r.user_id}:${r.plan_id}`, r])).values()];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.build({ label: plan.label, planId: plan.plan_id });
|
const notifications = revokedPairs.map((r) => {
|
||||||
await UserNotification.bulkCreate(
|
const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.build({
|
||||||
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
|
label: labelByPlanId.get(String(r.plan_id)),
|
||||||
{ validate: false },
|
planId: r.plan_id,
|
||||||
);
|
});
|
||||||
|
return { user_id: String(r.user_id), ...notify, seen: false, createdAt: now, updatedAt: now };
|
||||||
|
});
|
||||||
|
await UserNotification.bulkCreate(notifications, { validate: false });
|
||||||
} catch (notifyErr) {
|
} catch (notifyErr) {
|
||||||
console.error('[PLAN ACCESS REVOKE][NOTIFY]', notifyErr);
|
console.error('[PLAN ACCESS REVOKE][NOTIFY]', notifyErr);
|
||||||
}
|
}
|
||||||
@@ -70,13 +94,16 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
|
|||||||
where: { user_id: userIds },
|
where: { user_id: userIds },
|
||||||
attributes: ['user_id', 'email', 'personal_info'],
|
attributes: ['user_id', 'email', 'personal_info'],
|
||||||
});
|
});
|
||||||
|
const usersById = new Map(users.map((u) => [String(u.user_id), u]));
|
||||||
const dateStr = fmtDate(now);
|
const dateStr = fmtDate(now);
|
||||||
for (const u of users) {
|
for (const r of revokedPairs) {
|
||||||
|
const u = usersById.get(String(r.user_id));
|
||||||
|
if (!u) continue;
|
||||||
const name = u.personal_info?.name?.full_name ?? 'there';
|
const name = u.personal_info?.name?.full_name ?? 'there';
|
||||||
sendEmail({
|
sendEmail({
|
||||||
to: u.email,
|
to: u.email,
|
||||||
type: 'TIER_ACCESS_REVOKED',
|
type: 'TIER_ACCESS_REVOKED',
|
||||||
data: { name, label: plan.label, date: dateStr },
|
data: { name, label: labelByPlanId.get(String(r.plan_id)), date: dateStr },
|
||||||
}).catch((emailErr) => console.error('[PLAN ACCESS REVOKE][EMAIL]', emailErr));
|
}).catch((emailErr) => console.error('[PLAN ACCESS REVOKE][EMAIL]', emailErr));
|
||||||
}
|
}
|
||||||
} catch (emailBatchErr) {
|
} catch (emailBatchErr) {
|
||||||
@@ -86,4 +113,8 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
|
|||||||
return { revoked_user_count: userIds.length };
|
return { revoked_user_count: userIds.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { revokePlanSubscriberAccess };
|
async function revokePlanSubscriberAccess(plan, revokedByUserId) {
|
||||||
|
return revokePlanSubscriberAccessBulk([plan], revokedByUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk };
|
||||||
|
|||||||
@@ -10,8 +10,7 @@
|
|||||||
jest.mock('../../models/tiers/user_tiers.mdl', () => ({
|
jest.mock('../../models/tiers/user_tiers.mdl', () => ({
|
||||||
findAll: jest.fn(),
|
findAll: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
count: jest.fn(),
|
bulkCreate: jest.fn(),
|
||||||
create: jest.fn(),
|
|
||||||
}));
|
}));
|
||||||
jest.mock('../../models/users/users.mdl', () => ({ findAll: jest.fn() }));
|
jest.mock('../../models/users/users.mdl', () => ({ findAll: jest.fn() }));
|
||||||
jest.mock('../../models/notifications/user_notification.mdl', () => ({ bulkCreate: jest.fn() }));
|
jest.mock('../../models/notifications/user_notification.mdl', () => ({ bulkCreate: jest.fn() }));
|
||||||
@@ -22,7 +21,7 @@ const mdl_Users = require('../../models/users/users.mdl');
|
|||||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||||
const { sendEmail } = require('../../services/email.service');
|
const { sendEmail } = require('../../services/email.service');
|
||||||
|
|
||||||
const { revokePlanSubscriberAccess } = require('../../services/planAccess.service');
|
const { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk } = require('../../services/planAccess.service');
|
||||||
|
|
||||||
function makePlan(overrides = {}) {
|
function makePlan(overrides = {}) {
|
||||||
return { plan_id: 10, label: 'Premium – 1 Month', ...overrides };
|
return { plan_id: 10, label: 'Premium – 1 Month', ...overrides };
|
||||||
@@ -43,8 +42,9 @@ describe('revokePlanSubscriberAccess()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a user with ONLY this plan active gets auto-downgraded to Free', async () => {
|
test('a user with ONLY this plan active gets auto-downgraded to Free', async () => {
|
||||||
mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 1, user_id: 5 }]);
|
mdl_UserTiers.findAll
|
||||||
mdl_UserTiers.count.mockResolvedValue(0); // no other active tier remains
|
.mockResolvedValueOnce([{ tier_id: 1, user_id: 5, plan_id: 10 }]) // active rows for the plan
|
||||||
|
.mockResolvedValueOnce([]); // grouped "still active elsewhere" check — none
|
||||||
mdl_Users.findAll.mockResolvedValue([{ user_id: 5, email: 'a@b.com', personal_info: { name: { full_name: 'Ana' } } }]);
|
mdl_Users.findAll.mockResolvedValue([{ user_id: 5, email: 'a@b.com', personal_info: { name: { full_name: 'Ana' } } }]);
|
||||||
|
|
||||||
const result = await revokePlanSubscriberAccess(makePlan(), 99);
|
const result = await revokePlanSubscriberAccess(makePlan(), 99);
|
||||||
@@ -53,28 +53,30 @@ describe('revokePlanSubscriberAccess()', () => {
|
|||||||
expect.objectContaining({ status: 'revoked', revoked_by: 99 }),
|
expect.objectContaining({ status: 'revoked', revoked_by: 99 }),
|
||||||
{ where: { tier_id: [1] } }
|
{ where: { tier_id: [1] } }
|
||||||
);
|
);
|
||||||
expect(mdl_UserTiers.create).toHaveBeenCalledWith(
|
expect(mdl_UserTiers.bulkCreate).toHaveBeenCalledWith([
|
||||||
expect.objectContaining({ user_id: '5', tier: 'free', status: 'active' })
|
expect.objectContaining({ user_id: '5', tier: 'free', status: 'active' }),
|
||||||
);
|
]);
|
||||||
expect(result).toEqual({ revoked_user_count: 1 });
|
expect(result).toEqual({ revoked_user_count: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a user with ANOTHER concurrently-active plan does NOT get downgraded', async () => {
|
test('a user with ANOTHER concurrently-active plan does NOT get downgraded', async () => {
|
||||||
mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 2, user_id: 6 }]);
|
mdl_UserTiers.findAll
|
||||||
mdl_UserTiers.count.mockResolvedValue(1); // still holds a different active plan
|
.mockResolvedValueOnce([{ tier_id: 2, user_id: 6, plan_id: 10 }])
|
||||||
|
.mockResolvedValueOnce([{ user_id: 6 }]); // still holds a different active tier
|
||||||
mdl_Users.findAll.mockResolvedValue([{ user_id: 6, email: 'c@d.com', personal_info: {} }]);
|
mdl_Users.findAll.mockResolvedValue([{ user_id: 6, email: 'c@d.com', personal_info: {} }]);
|
||||||
|
|
||||||
await revokePlanSubscriberAccess(makePlan(), 99);
|
await revokePlanSubscriberAccess(makePlan(), 99);
|
||||||
|
|
||||||
expect(mdl_UserTiers.create).not.toHaveBeenCalled();
|
expect(mdl_UserTiers.bulkCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fires exactly one notification batch and one email per affected user', async () => {
|
test('fires exactly one notification batch and one email per affected user', async () => {
|
||||||
mdl_UserTiers.findAll.mockResolvedValue([
|
mdl_UserTiers.findAll
|
||||||
{ tier_id: 1, user_id: 5 },
|
.mockResolvedValueOnce([
|
||||||
{ tier_id: 2, user_id: 6 },
|
{ tier_id: 1, user_id: 5, plan_id: 10 },
|
||||||
]);
|
{ tier_id: 2, user_id: 6, plan_id: 10 },
|
||||||
mdl_UserTiers.count.mockResolvedValue(1);
|
])
|
||||||
|
.mockResolvedValueOnce([{ user_id: 5 }, { user_id: 6 }]);
|
||||||
mdl_Users.findAll.mockResolvedValue([
|
mdl_Users.findAll.mockResolvedValue([
|
||||||
{ user_id: 5, email: 'a@b.com', personal_info: {} },
|
{ user_id: 5, email: 'a@b.com', personal_info: {} },
|
||||||
{ user_id: 6, email: 'c@d.com', personal_info: {} },
|
{ user_id: 6, email: 'c@d.com', personal_info: {} },
|
||||||
@@ -89,3 +91,37 @@ describe('revokePlanSubscriberAccess()', () => {
|
|||||||
expect(result).toEqual({ revoked_user_count: 2 });
|
expect(result).toEqual({ revoked_user_count: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('revokePlanSubscriberAccessBulk() — N+1 regression', () => {
|
||||||
|
test('query count stays flat regardless of plan/subscriber count (no per-user or per-plan loop)', async () => {
|
||||||
|
const plans = [
|
||||||
|
{ plan_id: 10, label: 'Premium – 1 Month' },
|
||||||
|
{ plan_id: 11, label: 'Premium – 1 Year' },
|
||||||
|
{ plan_id: 12, label: 'Basic' },
|
||||||
|
];
|
||||||
|
const activeRows = Array.from({ length: 25 }, (_, i) => ({
|
||||||
|
tier_id: i + 1,
|
||||||
|
user_id: i + 1,
|
||||||
|
plan_id: plans[i % plans.length].plan_id,
|
||||||
|
}));
|
||||||
|
mdl_UserTiers.findAll
|
||||||
|
.mockResolvedValueOnce(activeRows) // active rows across all 3 plans
|
||||||
|
.mockResolvedValueOnce([]); // grouped "still active" check — nobody else active
|
||||||
|
mdl_Users.findAll.mockResolvedValue(
|
||||||
|
activeRows.map((r) => ({ user_id: r.user_id, email: `${r.user_id}@x.com`, personal_info: {} })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await revokePlanSubscriberAccessBulk(plans, 99);
|
||||||
|
|
||||||
|
// Exactly 2 findAll calls total (active rows + grouped still-active check),
|
||||||
|
// 1 bulk update, 1 bulk downgrade create, 1 notification bulkCreate —
|
||||||
|
// no matter how many plans/users were involved.
|
||||||
|
expect(mdl_UserTiers.findAll).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mdl_UserTiers.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mdl_UserTiers.bulkCreate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mdl_UserTiers.bulkCreate.mock.calls[0][0]).toHaveLength(25);
|
||||||
|
expect(UserNotification.bulkCreate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(UserNotification.bulkCreate.mock.calls[0][0]).toHaveLength(25);
|
||||||
|
expect(result).toEqual({ revoked_user_count: 25 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+26
-19
@@ -1,33 +1,40 @@
|
|||||||
// utils/resolveAvatar.util.js
|
// utils/resolveAvatar.util.js
|
||||||
//
|
//
|
||||||
// Resolves a stored avatar into a browser-usable URL at read time.
|
// Resolves a stored avatar into a browser-usable reference at read time.
|
||||||
//
|
//
|
||||||
// personal_info.avatar.url is never trustworthy as stored:
|
// personal_info.avatar is never handed to the browser as a raw S3 URL —
|
||||||
// - S3-stored avatars (avatar.uuid present) were historically saved with a
|
// same protection as every other media type in this app (see
|
||||||
// raw, unsigned bucket URL (see s3.service.js buildPublicUrl). The current
|
// controllers/client/media.controller.js): the browser only ever gets a
|
||||||
// Garage ingress (Cloudflare lane, see chibistar/Caddyfile) forwards reads
|
// short-lived opaque stream_token, proxied through
|
||||||
// straight to Garage with no re-signing, and Garage rejects anonymous
|
// GET /client/media/stream/:token, which mints the real presigned URL
|
||||||
// requests outright — so that stored URL 403s in the browser. Even a
|
// server-side and pipes the bytes back. The real bucket/key/signature never
|
||||||
// presigned URL would go stale if persisted (getPublicUrl() expires in 4h),
|
// reach the DOM.
|
||||||
// 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.
|
// - S3-stored avatars (avatar.uuid present) → sign a fresh media JWT from
|
||||||
|
// the stored key on every read (never persist a token/URL on the row).
|
||||||
// - Google-picture avatars (reg_type: 'google', no uuid — see
|
// - Google-picture avatars (reg_type: 'google', no uuid — see
|
||||||
// auth.controller.js googleCallback) are an external CDN URL and pass
|
// auth.controller.js googleCallback) are an external CDN URL and pass
|
||||||
// through unchanged; there's nothing of ours to sign.
|
// through unchanged as file_url; there's nothing of ours to sign or hide.
|
||||||
//
|
//
|
||||||
const { getPublicUrl } = require('../services/s3.service');
|
const { signMediaToken } = require('../services/mediaToken.service');
|
||||||
|
|
||||||
async function resolveAvatarUrl(avatar) {
|
async function resolveAvatarUrl(avatar) {
|
||||||
if (!avatar) return avatar ?? null;
|
if (!avatar) return avatar ?? null;
|
||||||
if (!avatar.uuid) return avatar; // external URL (e.g. Google) — nothing to sign
|
|
||||||
|
|
||||||
try {
|
if (!avatar.uuid) {
|
||||||
const url = await getPublicUrl(avatar.uuid);
|
// External URL (e.g. Google) — nothing to sign, just normalize the field
|
||||||
return { ...avatar, url };
|
// name to match the { stream_token?, file_url? } shape resolveAssetSrc()
|
||||||
} catch (err) {
|
// already expects for every other media payload.
|
||||||
console.error('[AVATAR] Failed to resolve presigned URL for', avatar.uuid, err);
|
return { ...avatar, url: undefined, file_url: avatar.url };
|
||||||
return avatar; // fall back to the stored value rather than failing the whole response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = signMediaToken({
|
||||||
|
storage_key: avatar.uuid,
|
||||||
|
file_type: 'image',
|
||||||
|
mime_type: avatar.mime_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...avatar, url: undefined, file_url: undefined, stream_token: token };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mutates-and-returns a shallow copy of a user (plain object or Sequelize
|
// Mutates-and-returns a shallow copy of a user (plain object or Sequelize
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name : suspendGuard.util.js
|
||||||
|
* Type : Utility
|
||||||
|
* Description : Local-dev-only watchdog that detects the host machine coming
|
||||||
|
* back from sleep/idle (laptop lid closed, suspended, etc.)
|
||||||
|
* and stops the dev server immediately.
|
||||||
|
*
|
||||||
|
* Why: when the process is frozen mid-sleep, node-cron's own
|
||||||
|
* heartbeat later finds itself hours behind schedule and logs
|
||||||
|
* a "[NODE-CRON] missed execution" warning for every tick that
|
||||||
|
* elapsed — one line per missed minute, easily hundreds after
|
||||||
|
* an overnight sleep. There's nothing to recover here (no
|
||||||
|
* request was dropped, no job silently failed); the correct
|
||||||
|
* behavior is just "the dev server wasn't meaningfully running
|
||||||
|
* during that time," so we exit instead of logging noise.
|
||||||
|
*
|
||||||
|
* Never runs when NODE_ENV=production — the droplet process
|
||||||
|
* is long-running and must never self-exit on its own.
|
||||||
|
*
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Aug. 11, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 250;
|
||||||
|
const GAP_THRESHOLD_MS = 10_000; // far beyond normal event-loop jitter
|
||||||
|
|
||||||
|
function startSuspendGuard() {
|
||||||
|
if (process.env.NODE_ENV === 'production') return;
|
||||||
|
|
||||||
|
let last = Date.now();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const gap = now - last - CHECK_INTERVAL_MS;
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
if (gap > GAP_THRESHOLD_MS) {
|
||||||
|
console.log(
|
||||||
|
`\n🛑 Dev server was asleep/idle for ~${Math.round(gap / 1000)}s (laptop suspend or similar). ` +
|
||||||
|
`Stopping instead of letting node-cron dump missed-execution warnings.\n`
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL_MS).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startSuspendGuard };
|
||||||
Reference in New Issue
Block a user