mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -2,11 +2,39 @@
|
||||
|
||||
const Advertisement = require("../../models/advertisements/advertisements.mdl");
|
||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { PLACEMENT_MAP } = require("../../models/advertisements/advertisements.placements");
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
|
||||
const AD_IMAGE_INCLUDE = {
|
||||
model: mdl_Assets,
|
||||
as: "image",
|
||||
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
|
||||
required: false,
|
||||
};
|
||||
|
||||
const AD_CLIENT_EXCLUDE = ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"];
|
||||
|
||||
// ─── Media proxying ─────────────────────────────────────────────────────────
|
||||
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken.
|
||||
// Kept duplicated rather than shared to avoid a cross-boundary import between
|
||||
// admin and client controllers (same rationale as deriveStatus above). Private
|
||||
// (S3-backed) images never expose a raw file_url — the frontend resolves the
|
||||
// stream_token through GET /api/client/media/stream/:token instead.
|
||||
async function attachImageStreamToken(image, req) {
|
||||
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||
return image;
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||
image.stream_token = token;
|
||||
image.file_url = null;
|
||||
image.thumbnail_url = null;
|
||||
delete image.storage_key;
|
||||
return image;
|
||||
}
|
||||
|
||||
// ─── Status derivation ─────────────────────────────────────────────────────
|
||||
// Mirrors admin controller's deriveStatus — single source of truth for what
|
||||
@@ -25,42 +53,41 @@ function deriveStatus(advertisement) {
|
||||
return "active";
|
||||
}
|
||||
|
||||
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolves the single highest-priority live advertisement for a given placement
|
||||
// type. "Live" means is_active = true AND within start_date/end_date window —
|
||||
// ─── Live window helper ─────────────────────────────────────────────────────
|
||||
// "Live" means is_active = true AND within start_date/end_date window —
|
||||
// computed the same way as deriveStatus, but expressed as a SQL WHERE clause
|
||||
// here since we want the DB to do the filtering/ordering, not JS.
|
||||
function liveWhere(extra) {
|
||||
const now = new Date();
|
||||
return {
|
||||
...extra,
|
||||
is_active: true,
|
||||
deletedAt: null,
|
||||
[Op.and]: [
|
||||
{ [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] },
|
||||
{ [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// GET /api/client/advertisements/active?type=hero
|
||||
// Resolves the single highest-priority live advertisement for a given placement.
|
||||
//
|
||||
// GET /api/client/advertisements/active?placement=dashboard.hero
|
||||
//
|
||||
exports.getActiveAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { type } = req.query;
|
||||
const { placement } = req.query;
|
||||
|
||||
if (!type) return R.error(res, "type is required.", 400);
|
||||
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400);
|
||||
|
||||
const now = new Date();
|
||||
if (!placement) return R.error(res, "placement is required.", 400);
|
||||
if (!PLACEMENT_MAP[placement]) return R.error(res, "Invalid placement.", 400);
|
||||
|
||||
const advertisement = await Advertisement.findOne({
|
||||
where: {
|
||||
type,
|
||||
is_active: true,
|
||||
deletedAt: null,
|
||||
[Op.and]: [
|
||||
{ [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] },
|
||||
{ [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] },
|
||||
],
|
||||
},
|
||||
where: liveWhere({ placement }),
|
||||
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||
include: [{
|
||||
model: mdl_Assets,
|
||||
as: "image",
|
||||
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
|
||||
required: false,
|
||||
}],
|
||||
attributes: { exclude: ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"] },
|
||||
include: [AD_IMAGE_INCLUDE],
|
||||
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||
});
|
||||
|
||||
if (!advertisement) return R.success(res, "No active advertisement for this placement.", { data: null });
|
||||
@@ -68,6 +95,8 @@ exports.getActiveAdvertisement = async (req, res) => {
|
||||
const json = advertisement.toJSON();
|
||||
json.status = deriveStatus(json); // will always be "active" given the WHERE clause, but kept for shape consistency
|
||||
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
|
||||
return R.success(res, "Active advertisement retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE]", err);
|
||||
@@ -75,6 +104,51 @@ exports.getActiveAdvertisement = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ACTIVE (batch) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolves the highest-priority live advertisement for each of several
|
||||
// placements in a single round-trip — pages that need multiple simultaneous
|
||||
// slots (e.g. dashboard.hero + dashboard.popup) use this instead of N calls
|
||||
// to /active.
|
||||
//
|
||||
// GET /api/client/advertisements/active-batch?placements=dashboard.hero,dashboard.popup
|
||||
//
|
||||
exports.getActiveAdvertisements = async (req, res) => {
|
||||
try {
|
||||
const raw = req.query.placements;
|
||||
const placements = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!placements.length) return R.error(res, "placements is required.", 400);
|
||||
|
||||
const invalid = placements.filter((p) => !PLACEMENT_MAP[p]);
|
||||
if (invalid.length) return R.error(res, `Invalid placement(s): ${invalid.join(", ")}`, 400);
|
||||
|
||||
const advertisements = await Advertisement.findAll({
|
||||
where: liveWhere({ placement: { [Op.in]: placements } }),
|
||||
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||
include: [AD_IMAGE_INCLUDE],
|
||||
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||
});
|
||||
|
||||
// Keep only the highest-priority row per placement (order ASC, createdAt DESC already applied).
|
||||
const data = Object.fromEntries(placements.map((p) => [p, null]));
|
||||
for (const ad of advertisements) {
|
||||
const json = ad.toJSON();
|
||||
if (data[json.placement] !== null) continue; // already have the winner for this placement
|
||||
json.status = deriveStatus(json);
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
data[json.placement] = json;
|
||||
}
|
||||
|
||||
return R.success(res, "Active advertisements retrieved.", { data });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE BATCH]", err);
|
||||
return R.error(res, "Could not retrieve advertisements.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── TRACK CLICK ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// POST /api/client/advertisements/:advertisementId/click
|
||||
|
||||
@@ -14,49 +14,17 @@
|
||||
const R = require('../../utils/response.util');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { generateCertificate } = require('../../services/certificate.service');
|
||||
const { formatDuration } = require('../../utils/duration.util');
|
||||
const { ensureCertificateRecord, formatInstructors } = require('../../services/certificate-record.service');
|
||||
const { fmtDate } = require('../../utils/datetime.util');
|
||||
|
||||
const {
|
||||
Course,
|
||||
CourseAssessment,
|
||||
QuizAttempt,
|
||||
Certificate,
|
||||
CourseInstructor,
|
||||
} = require('../../models/courses/courses.associations');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
|
||||
// userCertSeq = how many certs this user will have after this insert
|
||||
async function buildCertNo(userId) {
|
||||
const count = await Certificate.count({ where: { user_id: userId } });
|
||||
const seq = String(count + 1).padStart(5, '0');
|
||||
const uid = String(userId).padStart(6, '0');
|
||||
const now = new Date();
|
||||
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
return `${YYYYMM}-${uid}-${seq}`;
|
||||
}
|
||||
|
||||
// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs)
|
||||
async function buildRefNo() {
|
||||
const count = await Certificate.count();
|
||||
const seq = String(count + 1).padStart(5, '0');
|
||||
const now = new Date();
|
||||
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
return `PP-${YYYYMM}-${seq}`;
|
||||
}
|
||||
|
||||
function formatInstructors(rows) {
|
||||
const names = rows.map(r => r.display_name);
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return names[0];
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
||||
return `${names[0]}, ${names[1]} and et. al`;
|
||||
}
|
||||
|
||||
// ─── GET /api/client/certificates/:courseUuid ──────────────────────────────────
|
||||
|
||||
exports.getCertificate = async (req, res) => {
|
||||
@@ -91,53 +59,27 @@ exports.getCertificate = async (req, res) => {
|
||||
return R.error(res, 'This course does not have an assessment — no certificate available.', 404);
|
||||
}
|
||||
|
||||
// ── 2. Verify the user passed ──────────────────────────────────────────────
|
||||
const passedAttempt = await QuizAttempt.findOne({
|
||||
where: {
|
||||
user_id,
|
||||
assessment_id: course.assessment.assessment_id,
|
||||
passed: true,
|
||||
},
|
||||
order: [['createdAt', 'DESC']],
|
||||
attributes: ['score', 'createdAt'],
|
||||
});
|
||||
|
||||
if (!passedAttempt) {
|
||||
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
|
||||
}
|
||||
|
||||
// ── 3. Get user's name ─────────────────────────────────────────────────────
|
||||
// ── 2. Get user's name ──────────────────────────────────────────────────────
|
||||
const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] });
|
||||
const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant';
|
||||
|
||||
// ── 4. Resolve or create the certificate record ────────────────────────────
|
||||
// CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions).
|
||||
let cert = await Certificate.findOne({ where: { user_id, course_id: course.course_id } });
|
||||
// ── 3. Resolve or create the certificate record ─────────────────────────────
|
||||
// Shared with the hourly issuance cron (cron/jobs/issue_certificates.cron.js)
|
||||
// so both write through the same cert_no/ref_no sequence.
|
||||
const cert = await ensureCertificateRecord({ userId: user_id, courseId: course.course_id });
|
||||
if (!cert) {
|
||||
cert = await Certificate.create({
|
||||
user_id,
|
||||
course_id: course.course_id,
|
||||
cert_no: await buildCertNo(user_id),
|
||||
ref_no: await buildRefNo(),
|
||||
instructors: formatInstructors(course.instructors ?? []),
|
||||
score: passedAttempt.score ?? null,
|
||||
length_str: formatDuration(course.duration_seconds),
|
||||
issued_at: passedAttempt.createdAt,
|
||||
});
|
||||
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
|
||||
}
|
||||
|
||||
// Always use live instructors from course_instructors table for the PDF.
|
||||
// Keep the snapshot in sync so it reflects the current state.
|
||||
// Always use live instructors from course_instructors table for the PDF,
|
||||
// in case they changed since the certificate row was created.
|
||||
const liveInstructors = formatInstructors(course.instructors ?? []);
|
||||
if (liveInstructors !== (cert.instructors ?? '')) {
|
||||
await cert.update({ instructors: liveInstructors });
|
||||
}
|
||||
|
||||
// ── 5. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
|
||||
// ── 4. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
|
||||
const issuedDate = new Date(cert.issued_at);
|
||||
const dateStr = fmtDate(issuedDate);
|
||||
|
||||
// ── 6. Generate PDF ────────────────────────────────────────────────────────
|
||||
// ── 5. Generate PDF ────────────────────────────────────────────────────────
|
||||
const pdf = await generateCertificate({
|
||||
name: fullName,
|
||||
course: course.title,
|
||||
@@ -148,7 +90,7 @@ exports.getCertificate = async (req, res) => {
|
||||
length: cert.length_str ?? '',
|
||||
});
|
||||
|
||||
// ── 7. Stream response ─────────────────────────────────────────────────────
|
||||
// ── 6. Stream response ─────────────────────────────────────────────────────
|
||||
const nameParts = fullName.trim().split(/\s+/);
|
||||
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0];
|
||||
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
|
||||
|
||||
@@ -958,7 +958,7 @@ exports.submitCourseAssessment = async (req, res) => {
|
||||
// Immediate notification: course completed, certificate incoming
|
||||
UserNotification.create({
|
||||
user_id,
|
||||
...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '' }),
|
||||
...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null }),
|
||||
}).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,23 @@ function trackToken(token, ip) {
|
||||
|
||||
// ─── Helper: resolve client IP ───────────────────────────────────────────────
|
||||
|
||||
// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1")
|
||||
// down to a single canonical form. Without this, a token minted off one
|
||||
// "localhost" connection (IPv4) fails IP-pin verification on a sibling
|
||||
// request that happened to land on the other stack (IPv6) — browsers race
|
||||
// both when resolving "localhost", so mint and stream requests can land on
|
||||
// different stacks even from the same client.
|
||||
function normalizeIp(ip) {
|
||||
if (ip === "::1") return "127.0.0.1";
|
||||
if (ip.startsWith("::ffff:")) return ip.slice(7);
|
||||
return ip;
|
||||
}
|
||||
|
||||
function resolveIp(req) {
|
||||
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
if (forwarded) return forwarded.split(",")[0].trim();
|
||||
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
|
||||
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
|
||||
return normalizeIp(raw);
|
||||
}
|
||||
|
||||
// ─── Helper: pipe S3 pre-signed URL to response (Range-aware) ────────────────
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* GET /client/notifications/unseen — unseen count
|
||||
* PATCH /client/notifications/:id/seen — mark one as seen
|
||||
* PATCH /client/notifications/seen-all — mark all as seen
|
||||
* DELETE /client/notifications/clear-all — delete all notifications
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 19, 2026
|
||||
@@ -83,4 +84,17 @@ async function markAllSeen(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { list, unseenCount, markSeen, markAllSeen };
|
||||
// ─── DELETE /client/notifications/clear-all ──────────────────────────────────
|
||||
async function clearAll(req, res) {
|
||||
try {
|
||||
const count = await UserNotification.destroy({
|
||||
where: { user_id: req.user.user_id },
|
||||
});
|
||||
return R.success(res, `${count} notification(s) cleared.`, { count });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT NOTIFICATION] clearAll error:', err);
|
||||
return R.error(res, 'Failed to clear notifications.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { list, unseenCount, markSeen, markAllSeen, clearAll };
|
||||
|
||||
@@ -66,23 +66,6 @@ exports.updateProfile = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PATCH preferred currency ──────────────────────────────────────────────────
|
||||
|
||||
exports.updateCurrency = async (req, res) => {
|
||||
try {
|
||||
const { currency } = req.body;
|
||||
if (!currency || typeof currency !== 'string' || currency.length !== 3)
|
||||
return R.error(res, 'A valid 3-letter ISO 4217 currency code is required.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
await user.update({ preferred_currency: currency.toUpperCase() });
|
||||
return R.success(res, 'Currency preference updated.', { preferred_currency: user.preferred_currency });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT] updateCurrency error:', err);
|
||||
return R.error(res, 'Could not update currency preference.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET own sessions ──────────────────────────────────────────────────────────
|
||||
|
||||
exports.getSessions = async (req, res) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ const { userExclude } = require('../../models/task/task.attributes');
|
||||
const { clientExclude } = require('../../models/task/task_completion.attributes');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const isUUID = (v) => UUID_RE.test(v);
|
||||
@@ -45,13 +46,32 @@ exports.getMyGroups = async (req, res) => {
|
||||
attributes: [],
|
||||
through: {
|
||||
model: mdl_UserGroupMembers,
|
||||
attributes: ['joined_at'],
|
||||
attributes: [],
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
model: TaskList,
|
||||
as: 'taskLists',
|
||||
attributes: [],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
where: { is_active: true },
|
||||
attributes: ['group_id', 'name', 'group_code', 'description'],
|
||||
attributes: [
|
||||
'group_id',
|
||||
'name',
|
||||
'group_code',
|
||||
'description',
|
||||
[sequelize.fn('COUNT', sequelize.fn('DISTINCT', sequelize.col('taskLists.task_list_id'))), 'task_list_count'],
|
||||
],
|
||||
group: [
|
||||
'UserGroup.group_id',
|
||||
'UserGroup.name',
|
||||
'UserGroup.group_code',
|
||||
'UserGroup.description',
|
||||
],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
@@ -175,6 +195,13 @@ exports.getGroupTaskList = async (req, res) => {
|
||||
const json = taskList.toJSON();
|
||||
const tasks = json.tasks ?? [];
|
||||
const taskIds = tasks.map((t) => t.task_id);
|
||||
const readRequirements = tasks.flatMap((task) =>
|
||||
(task.requirements ?? [])
|
||||
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
|
||||
.map((req) => ({ ...req, task_id: task.task_id }))
|
||||
);
|
||||
|
||||
await hydrateReadTaskProgress(userId, readRequirements);
|
||||
|
||||
// ── Fetch user's completion signals for these tasks ────────────────────
|
||||
const [completions, linkVisits, progressRows] = await Promise.all([
|
||||
@@ -316,6 +343,18 @@ exports.getGroupTaskLists = async (req, res) => {
|
||||
// ── Gather all task_ids across the group's task lists ──────────────────
|
||||
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
|
||||
const taskIds = allTasks.map((t) => t.task_id);
|
||||
const readRequirements = allTasks.flatMap((task) =>
|
||||
(task.requirements ?? [])
|
||||
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
|
||||
.map((req) => ({
|
||||
task_id: task.task_id,
|
||||
requirement_id: req.requirement_id,
|
||||
reference_id: req.reference_id,
|
||||
type: req.type,
|
||||
}))
|
||||
);
|
||||
|
||||
await hydrateReadTaskProgress(userId, readRequirements);
|
||||
|
||||
// ── Fetch user's completion signals for these tasks ────────────────────
|
||||
const [completions, linkVisits, progressRows] = await Promise.all([
|
||||
@@ -684,4 +723,4 @@ exports.getLatestCompletion = async (req, res) => {
|
||||
console.error('[CLIENT][GET LATEST COMPLETION]', err);
|
||||
return R.error(res, 'Could not retrieve latest completion.', 500);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 13, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
|
||||
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
|
||||
@@ -28,6 +29,7 @@ const { TaskLinkVisit, TaskProgress } = require('../../models/task/task
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const isUUID = (v) => UUID_RE.test(v);
|
||||
@@ -101,6 +103,16 @@ exports.getTaskProgress = async (req, res) => {
|
||||
});
|
||||
if (!task) return R.error(res, 'Task not found.', 404);
|
||||
|
||||
const readRequirements = await TaskRequirement.findAll({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] },
|
||||
},
|
||||
attributes: ['task_id', 'requirement_id', 'reference_id', 'type'],
|
||||
});
|
||||
|
||||
await hydrateReadTaskProgress(req.user.user_id, readRequirements);
|
||||
|
||||
const [linkVisits, progress] = await Promise.all([
|
||||
TaskLinkVisit.findAll({
|
||||
where: { task_id: taskId, user_id: req.user.user_id },
|
||||
@@ -410,4 +422,4 @@ exports.updateProgress = async (req, res) => {
|
||||
// console.error('[CLIENT][GET LATEST COMPLETION]', err);
|
||||
// return R.error(res, 'Could not retrieve latest completion.', 500);
|
||||
// }
|
||||
// };
|
||||
// };
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
***********************************************************************************************************************************************************************/
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl');
|
||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
|
||||
@@ -53,8 +52,9 @@ exports.getMyTier = async (req, res) => {
|
||||
UserNotification.create({
|
||||
user_id: req.user.user_id,
|
||||
...NOTIFICATION_REGISTRY.tier_expired.build({
|
||||
tier: tier.tier,
|
||||
label: tier.plan?.label ?? null,
|
||||
tier: tier.tier,
|
||||
label: tier.plan?.label ?? null,
|
||||
planId: tier.plan?.plan_id ?? null,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
return R.success(res, 'Active tier retrieved.', {
|
||||
@@ -114,11 +114,6 @@ exports.getPlans = async (req, res) => {
|
||||
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: mdl_PlanPrices,
|
||||
as: 'prices',
|
||||
attributes: ['currency', 'price'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -139,21 +134,14 @@ exports.getPlans = async (req, res) => {
|
||||
|
||||
exports.validatePromo = async (req, res) => {
|
||||
try {
|
||||
const { plan_id, code, currency } = req.body;
|
||||
const { plan_id, code } = req.body;
|
||||
if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400);
|
||||
|
||||
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
|
||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
||||
|
||||
// Resolve localized price if a preferred currency was sent
|
||||
let effectivePrice = null;
|
||||
if (currency && currency !== plan.currency) {
|
||||
const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency } });
|
||||
if (priceEntry) effectivePrice = priceEntry.price;
|
||||
}
|
||||
|
||||
const policy = await paymentSvc.getPolicyForPlan(plan_id);
|
||||
const result = await paymentSvc.evaluatePromo(policy, plan, code, effectivePrice);
|
||||
const result = await paymentSvc.evaluatePromo(policy, plan, code, null);
|
||||
|
||||
return R.success(res, result.valid ? 'Promo code is valid.' : result.reason, result);
|
||||
} catch (err) {
|
||||
@@ -166,22 +154,14 @@ exports.validatePromo = async (req, res) => {
|
||||
|
||||
exports.createOrder = async (req, res) => {
|
||||
try {
|
||||
const { plan_id, promo_code, currency: requestedCurrency } = req.body;
|
||||
const { plan_id, promo_code } = req.body;
|
||||
if (!plan_id) return R.error(res, 'plan_id is required.', 400);
|
||||
|
||||
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
|
||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
||||
|
||||
// Resolve localized price — falls back to plan base price when no override exists
|
||||
let effectivePrice = Number(plan.price);
|
||||
let effectiveCurrency = plan.currency;
|
||||
if (requestedCurrency && requestedCurrency !== plan.currency) {
|
||||
const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency: requestedCurrency } });
|
||||
if (priceEntry) {
|
||||
effectivePrice = Number(priceEntry.price);
|
||||
effectiveCurrency = priceEntry.currency;
|
||||
}
|
||||
}
|
||||
const effectivePrice = Number(plan.price);
|
||||
const effectiveCurrency = plan.currency;
|
||||
|
||||
const policy = await paymentSvc.getPolicyForPlan(plan_id);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user