Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 12:39:17 +08:00
parent 82ea9c77c4
commit ea3e82e54c
47 changed files with 1301 additions and 481 deletions
@@ -189,6 +189,38 @@ exports.getActiveAdvertisements = async (req, res) => {
}
};
// ─── GET BY UUID ──────────────────────────────────────────────────────────────
//
// Resolves a single live advertisement by uuid for its own landing page — the
// destination CTA/banner clicks resolve to when the ad has no redirect_link
// (see /ads/:uuid on the client).
//
// GET /api/client/advertisements/uuid/:uuid
//
exports.getAdvertisementByUuid = async (req, res) => {
try {
const { uuid } = req.params;
if (!uuid) return R.error(res, "uuid is required.", 400);
const advertisement = await Advertisement.findOne({
where: liveWhere({ uuid }),
include: [AD_IMAGE_INCLUDE],
attributes: { exclude: AD_CLIENT_EXCLUDE },
});
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
const json = advertisement.toJSON();
json.status = deriveStatus(json);
if (json.image) await attachImageStreamToken(json.image, req);
return R.success(res, "Advertisement retrieved.", { data: json });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET BY UUID]", err);
return R.error(res, "Could not retrieve advertisement.", 500);
}
};
// ─── TRACK CLICK ──────────────────────────────────────────────────────────────
//
// POST /api/client/advertisements/:advertisementId/click
+33 -4
View File
@@ -40,7 +40,7 @@ const { onCourseCompleted } = require('../../services/achievements.service'
const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const notDeleted = { deletedAt: null };
@@ -468,6 +468,10 @@ exports.getUnit = async (req, res) => {
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404);
if (!await canAccessUnit(req.user.user_id, unitId)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const unit = await Unit.findOne({
where: { unit_id: unitId, ...notDeleted },
attributes: [
@@ -519,6 +523,10 @@ exports.getLesson = async (req, res) => {
]);
if (!courseLink || !lessonLink) return R.error(res, "Lesson not found.", 404);
if (!await canAccessLesson(req.user.user_id, lessonId)) {
return R.error(res, "You do not have access to this lesson.", 403);
}
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, ...notDeleted },
attributes: [
@@ -557,6 +565,10 @@ exports.getUnitQuiz = async (req, res) => {
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404);
if (!await canAccessUnit(req.user.user_id, unitId)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const quiz = await UnitQuiz.findOne({
where: { unit_id: unitId, ...notDeleted },
attributes: [
@@ -615,6 +627,10 @@ exports.getCourseAssessment = async (req, res) => {
try {
const { courseId } = req.params;
if (!await canAccessCourse(req.user.user_id, courseId)) {
return R.error(res, "You do not have access to this course.", 403);
}
const assessment = await CourseAssessment.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: [
@@ -698,6 +714,10 @@ exports.startCourseAssessment = async (req, res) => {
const { courseId, assessmentId } = req.params;
const user_id = req.user.user_id;
if (!await canAccessCourse(user_id, courseId)) {
return R.error(res, "You do not have access to this course.", 403);
}
const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
attributes: ["assessment_id", "time_limit_minutes", "passing_score", "max_attempts", "cooldown_hours"],
@@ -857,6 +877,10 @@ exports.submitUnitQuiz = async (req, res) => {
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404);
if (!await canAccessUnit(user_id, unitId)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const quiz = await UnitQuiz.findOne({
where: { quiz_id: quizId, unit_id: unitId, ...notDeleted },
include: [{
@@ -949,6 +973,10 @@ exports.submitCourseAssessment = async (req, res) => {
const { answers = {}, session_id } = req.body;
const user_id = req.user.user_id;
if (!await canAccessCourse(user_id, courseId)) {
return R.error(res, "You do not have access to this course.", 403);
}
const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
include: [{
@@ -1038,9 +1066,10 @@ exports.submitCourseAssessment = async (req, res) => {
}
// Immediate notification: course completed, certificate incoming
renderNotification({ type: 'course_completed', data: { courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null } })
.then(notify => UserNotification.create({ user_id, ...notify }))
.catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
UserNotification.create({
user_id,
...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null }),
}).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
}
return R.success(res, "Assessment submitted.", {
+57 -13
View File
@@ -12,7 +12,45 @@
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const UserNotification = require('../../models/notifications/user_notification.mdl');
const StickyBannerSetting = require('../../models/notifications/sticky_banner_setting.mdl');
const mdl_Assets = require('../../models/assets/assets.mdl');
const mediaToken = require('../../services/mediaToken.service');
const R = require('../../utils/response.util');
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
const STICKY_LIMIT = 3;
const 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,
};
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken
// — kept duplicated rather than shared across the admin/client boundary.
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;
}
// One shared banner image for the whole rotating sticky bar (see
// controllers/admin/notificationBroadcasts.controller.js's
// getStickyBannerSetting/updateStickyBannerSetting) — not per-announcement.
async function resolveSharedBannerImage(req) {
const setting = await StickyBannerSetting.findOne({ where: { id: 1 }, include: [IMAGE_INCLUDE] });
if (!setting?.image) return null;
const image = setting.toJSON().image;
await attachImageStreamToken(image, req);
return image;
}
// ─── GET /client/notifications ────────────────────────────────────────────────
async function list(req, res) {
@@ -23,7 +61,7 @@ async function list(req, res) {
const offset = (page - 1) * limit;
const { count, rows } = await UserNotification.findAndCountAll({
where: { user_id: userId, show_in_notifications: true },
where: { user_id: userId, show_in_notifications: true, ...notInFutureOrExpired() },
order: [['createdAt', 'DESC']],
limit,
offset,
@@ -44,7 +82,7 @@ async function unseenCount(req, res) {
if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null });
try {
const count = await UserNotification.count({
where: { user_id: req.user.user_id, seen: false, show_in_notifications: true },
where: { user_id: req.user.user_id, seen: false, show_in_notifications: true, ...notInFutureOrExpired() },
});
return R.success(res, 'Unseen count fetched.', { count });
} catch (err) {
@@ -56,18 +94,24 @@ async function unseenCount(req, res) {
// ─── GET /client/notifications/sticky ─────────────────────────────────────
async function stickyAnnouncement(req, res) {
try {
const notification = await UserNotification.findOne({
where: {
user_id: req.user.user_id,
seen: false,
show_in_sticky: true,
type: "announcement",
},
order: [["createdAt", "DESC"]],
});
const [notifications, bannerImage] = await Promise.all([
UserNotification.findAll({
where: {
user_id: req.user.user_id,
seen: false,
show_in_sticky: true,
type: "announcement",
...notInFutureOrExpired(),
},
order: [["createdAt", "DESC"]],
limit: STICKY_LIMIT,
}),
resolveSharedBannerImage(req),
]);
return R.success(res, "Sticky announcement fetched.", {
announcement: notification,
return R.success(res, "Sticky announcements fetched.", {
announcements: notifications,
bannerImage,
});
} catch (err) {
console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err);
+2 -2
View File
@@ -25,7 +25,7 @@ const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { onTaskCompleted, onTaskListCompleted } = require('../../services/achievements.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;
@@ -248,7 +248,7 @@ const fireTaskCompletedEvent = async (userId, taskId) => {
if (!task) return;
try {
const notify = await renderNotification({ type: 'task_completed', data: { taskName: task.name } });
const notify = NOTIFICATION_REGISTRY.task_completed.build({ taskName: task.name });
await UserNotification.create({ user_id: userId, ...notify, seen: false });
} catch (notifyErr) {
console.error('[TASK][NOTIFY COMPLETED]', notifyErr);
+9 -8
View File
@@ -22,7 +22,7 @@ const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl');
const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
require('../../models/tiers/tier.associations');
@@ -49,13 +49,14 @@ exports.getMyTier = async (req, res) => {
// ── Inline safety net: expire between cron ticks ──────────────────────────
if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) {
await tier.update({ status: 'expired' });
renderNotification({ type: 'tier_expired', data: {
tier: tier.tier,
label: tier.plan?.label ?? null,
planId: tier.plan?.plan_id ?? null,
} })
.then(notify => UserNotification.create({ user_id: req.user.user_id, ...notify }))
.catch(() => {});
UserNotification.create({
user_id: req.user.user_id,
...NOTIFICATION_REGISTRY.tier_expired.build({
tier: tier.tier,
label: tier.plan?.label ?? null,
planId: tier.plan?.plan_id ?? null,
}),
}).catch(() => {});
return R.success(res, 'Active tier retrieved.', {
tier: 'free', status: 'active', category: null, just_expired: true,
});