mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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
|
||||
|
||||
@@ -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.", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user