new commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:20:58 +08:00
parent 1372f4e975
commit 1d12f04967
93 changed files with 3849 additions and 1063 deletions
+101 -27
View File
@@ -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
+12 -70
View File
@@ -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(' ') : '';
+1 -1
View File
@@ -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));
}
+14 -2
View File
@@ -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) ────────────────
+15 -1
View File
@@ -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 };
-17
View File
@@ -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) => {
+42 -3
View File
@@ -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);
}
};
};
+13 -1
View File
@@ -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);
// }
// };
// };
+8 -28
View File
@@ -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);