From e1ffdab1905667580acf693d7d8a7809be3c5a49 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Fri, 10 Jul 2026 11:44:46 +0800 Subject: [PATCH] units,lesson as standalone --- controllers/admin/courses.controller.js | 222 +++++++++++++ controllers/admin/lessons.controller.js | 18 ++ controllers/admin/notification.controller.js | 3 +- .../notificationBroadcasts.controller.js | 86 +++-- .../notification_templates.controller.js | 16 +- controllers/admin/task.controller.js | 70 +++- .../admin/task_completion.controller.js | 66 ++++ controllers/admin/tiers.controller.js | 6 +- controllers/admin/units.controller.js | 10 +- controllers/client/courses.controller.js | 119 ++++++- controllers/client/media.controller.js | 3 +- controllers/client/notification.controller.js | 28 +- controllers/client/task.controller.js | 305 ++++++++++++------ .../client/task_progress.controller.js | 56 +++- controllers/client/tiers.controller.js | 2 +- controllers/client/units.controller.js | 90 +++++- cron/client.cron.js | 2 + cron/jobs/task_due_soon.cron.js | 114 +++++++ data/notification_template_enrichers.data.js | 6 + ...00001-add-sticky-and-notification-flags.js | 57 ++++ ...-review-and-prompt-to-task-requirements.js | 30 ++ ...2-add-review-fields-to-task-completions.js | 55 ++++ ...003-add-pass-quiz-to-task-progress-type.js | 28 ++ ...-assign-complete-notification-templates.js | 42 +++ ...dd-order-index-and-is-required-to-tasks.js | 39 +++ ...260709000006-add-features-to-tier-plans.js | 19 ++ ...0260709000007-add-subscription-to-units.js | 23 ++ models/courses/lessons.mdl.js | 2 +- models/courses/units.mdl.js | 1 + .../notifications/admin_notification.mdl.js | 13 + .../notification_broadcast.mdl.js | 7 + models/notifications/user_notification.mdl.js | 15 + models/task/task.mdl.js | 15 +- models/task/task_completion.mdl.js | 20 +- models/task/task_progress.mdl.js | 4 +- models/tiers/tier_plans.mdl.js | 1 + routes/admin/admin.routes.js | 4 + routes/admin/courses.routes.js | 2 + routes/admin/task.routes.js | 2 + routes/client/courses.routes.js | 1 + routes/client/lessons.routes.js | 2 + routes/client/notifications.routes.js | 3 +- services/achievements.service.js | 12 + services/s3.service.js | 21 +- utils/buildQuery.util.js | 17 +- utils/paginate.util.js | 3 +- 46 files changed, 1463 insertions(+), 197 deletions(-) create mode 100644 cron/jobs/task_due_soon.cron.js create mode 100644 database/migrations/20260708000001-add-sticky-and-notification-flags.js create mode 100644 database/migrations/20260709000001-add-requires-review-and-prompt-to-task-requirements.js create mode 100644 database/migrations/20260709000002-add-review-fields-to-task-completions.js create mode 100644 database/migrations/20260709000003-add-pass-quiz-to-task-progress-type.js create mode 100644 database/migrations/20260709000004-add-task-review-assign-complete-notification-templates.js create mode 100644 database/migrations/20260709000005-add-order-index-and-is-required-to-tasks.js create mode 100644 database/migrations/20260709000006-add-features-to-tier-plans.js create mode 100644 database/migrations/20260709000007-add-subscription-to-units.js diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 669c7b8..71e15bd 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -169,6 +169,169 @@ exports.createCourse = async (req, res) => { } }; +// POST /full — single-shot Add Course wizard submit. Creates the course, +// its objectives/badge/achievement, and the whole units/lessons roadmap +// (attaching existing library items and/or creating new ones) in one +// transaction, so the wizard fires exactly one write instead of one per +// step/click. +// +// Body: +// { title, description, order_index, course_code, level, subscription, +// objectives: [string], category_ids: [id], +// badge_color, badge_asset_id, badge_image_url, achievement_keys: [key], +// units: [{ +// unit_id?, // attach existing library unit +// title, description, // create new unit when unit_id is absent +// lessons: [{ +// lesson_id?, // attach existing library lesson +// title, description, objectives, // create new lesson when lesson_id is absent +// }], +// }], +// createdBy } +exports.createCourseFull = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { + title, description, order_index, + course_code, level, subscription, + objectives = [], + category_ids = [], + achievement_keys = [], + badge_color, badge_asset_id, badge_image_url, + units = [], + createdBy, + } = req.body; + + if (!title) return R.error(res, "Title is required.", 400); + + for (const unit of units) { + if (!unit.unit_id && !unit.title) { + await t.rollback(); + return R.error(res, "Each new unit needs a title.", 400); + } + for (const lesson of unit.lessons ?? []) { + if (!lesson.lesson_id && !lesson.title) { + await t.rollback(); + return R.error(res, "Each new lesson needs a title.", 400); + } + } + } + + const course = await Course.create({ + title, + description: description ?? null, + order_index: order_index ?? 0, + course_code: course_code ?? null, + level: level ?? null, + subscription: subscription ?? "free", + duration_seconds: 0, + badge_color: badge_color ?? "purple", + badge_asset_id: badge_asset_id ?? null, + badge_image_url: badge_image_url ?? null, + createdBy: createdBy ?? null, + }, { transaction: t }); + + await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t); + await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t); + + if (achievement_keys.length) { + await CourseAchievement.bulkCreate( + achievement_keys.slice(0, 1).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })), + { transaction: t }, + ); + } + + const by = createdBy ?? req.user?.user_id ?? null; + const touchedUnitIds = []; + + for (let i = 0; i < units.length; i++) { + const unitInput = units[i]; + let unit; + + if (unitInput.unit_id) { + unit = await Unit.findOne({ where: { unit_id: unitInput.unit_id, ...notDeleted }, transaction: t }); + if (!unit) { + await t.rollback(); + return R.error(res, "One or more units were not found.", 404); + } + const otherLink = await CourseUnit.findOne({ where: { unit_id: unitInput.unit_id }, transaction: t }); + if (otherLink) { + await t.rollback(); + return R.error(res, `"${unit.title}" is already attached to another course.`, 409); + } + } else { + unit = await Unit.create({ + title: unitInput.title, + description: unitInput.description ?? null, + duration_seconds: 0, + createdBy: by, + }, { transaction: t }); + } + + await CourseUnit.create({ + course_id: course.course_id, + unit_id: unit.unit_id, + order_index: i, + createdBy: by, + }, { transaction: t }); + + const lessons = unitInput.lessons ?? []; + if (lessons.length) touchedUnitIds.push(unit.unit_id); + let lessonOrder = unitInput.unit_id ? await nextOrderIndex(UnitLesson, { unit_id: unit.unit_id }, t) : 0; + + for (const lessonInput of lessons) { + let lesson; + + if (lessonInput.lesson_id) { + lesson = await Lesson.findOne({ where: { lesson_id: lessonInput.lesson_id, ...notDeleted }, transaction: t }); + if (!lesson) { + await t.rollback(); + return R.error(res, "One or more lessons were not found.", 404); + } + const already = await getUnitLessonLink(unit.unit_id, lessonInput.lesson_id, t); + if (already) continue; // already attached to this unit, nothing to do + } else { + lesson = await Lesson.create({ + title: lessonInput.title, + description: lessonInput.description ?? null, + duration_seconds: 0, + createdBy: by, + }, { transaction: t }); + + await LessonPage.create({ + lesson_id: lesson.lesson_id, + blocks: [], + createdBy: by, + }, { transaction: t }); + + await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, lessonInput.objectives ?? [], t); + } + + await UnitLesson.create({ + unit_id: unit.unit_id, + lesson_id: lesson.lesson_id, + order_index: lessonOrder++, + createdBy: by, + }, { transaction: t }); + } + } + + await t.commit(); + + try { + for (const unitId of touchedUnitIds) await recomputeUnitDuration(unitId); + await recomputeCourseDuration(course.course_id); + } catch (durErr) { console.error("[COURSE][CREATE FULL][DURATION]", durErr); } + + logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title, units: units.length } }); + return R.success(res, "Course created.", { data: course }, 201); + } catch (err) { + await t.rollback(); + console.error("[COURSE][CREATE FULL]", err); + return R.error(res, "Could not create course.", 500); + } +}; + exports.updateCourse = async (req, res) => { const t = await sequelize.transaction(); try { @@ -521,6 +684,19 @@ exports.attachUnits = async (req, res) => { const existingSet = new Set(existing.map((r) => String(r.unit_id))); const toAttach = unit_ids.filter((id) => !existingSet.has(String(id))); + // A unit may only belong to one course at a time — reject the whole + // batch if any candidate is already linked elsewhere, rather than + // silently skipping (the admin should see and deselect it). + if (toAttach.length) { + const otherCourseLinks = await CourseUnit.findAll({ where: { unit_id: toAttach }, transaction: t }); + if (otherCourseLinks.length) { + await t.rollback(); + const blockedSet = new Set(otherCourseLinks.map((l) => String(l.unit_id))); + const blockedTitles = units.filter((u) => blockedSet.has(String(u.unit_id))).map((u) => u.title); + return R.error(res, `${blockedTitles.join(", ")} ${blockedTitles.length !== 1 ? "are" : "is"} already attached to another course.`, 409); + } + } + let order = await nextOrderIndex(CourseUnit, { course_id: courseId }, t); await CourseUnit.bulkCreate( toAttach.map((unit_id) => ({ @@ -631,6 +807,12 @@ exports.createUnit = async (req, res) => { await t.rollback(); return R.error(res, "Unit is already attached to this course.", 409); } + // A unit may only belong to one course at a time. + const otherLink = await CourseUnit.findOne({ where: { unit_id }, transaction: t }); + if (otherLink) { + await t.rollback(); + return R.error(res, "This unit is already attached to another course.", 409); + } } else { if (!title) return R.error(res, "Title is required.", 400); unit = await Unit.create({ @@ -2119,6 +2301,46 @@ exports.getLessonsFlat = async (req, res) => { return R.error(res, "Could not retrieve lessons.", 500); } }; + +// One row per unit quiz (a quiz is always unit-scoped, unit_id unique on +// unit_quizzes). Used by the pass_quiz task requirement picker — same +// "no content yet" convention as read_*: question_count === 0 is flagged +// the same way duration_seconds === 0 is for content requirements. +exports.getQuizzesFlat = async (req, res) => { + try { + const rows = await sequelize.query(` + SELECT + q.uuid, q.title, u.title AS unit_title, + COALESCE(c.title, '') AS course_title, + COALESCE(c.subscription, 'free') AS subscription, + (SELECT CAST(COUNT(*) AS INTEGER) FROM quiz_questions qq + WHERE qq.quiz_id = q.quiz_id AND qq."deletedAt" IS NULL) AS question_count + FROM unit_quizzes q + JOIN units u ON u.unit_id = q.unit_id AND u."deletedAt" IS NULL + LEFT JOIN course_units cu ON cu.unit_id = u.unit_id + LEFT JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL + WHERE q."deletedAt" IS NULL + ORDER BY course_title ASC, u.title ASC, q.title ASC + `, { type: sequelize.QueryTypes.SELECT }); + + const data = rows.map((r) => ({ + uuid: r.uuid, + title: r.title || `${r.unit_title} Quiz`, + unit_title: r.unit_title ?? "", + course_title: r.course_title ?? "", + subscription: r.subscription ?? "free", + question_count: Number(r.question_count ?? 0), + // duration_seconds doesn't apply to quizzes — ContentPicker's "no + // content" check keys off duration_seconds === 0, so surface the same + // signal under that name rather than adding a second code path. + duration_seconds: Number(r.question_count ?? 0), + })); + return R.success(res, "Quizzes retrieved.", data); + } catch (err) { + console.error("[QUIZ][GET FLAT]", err); + return R.error(res, "Could not retrieve quizzes.", 500); + } +}; // ══════════════════════════════════════════════════════════════════════════════ // COURSE INSTRUCTORS // ══════════════════════════════════════════════════════════════════════════════ diff --git a/controllers/admin/lessons.controller.js b/controllers/admin/lessons.controller.js index 59d75c8..d7f4678 100644 --- a/controllers/admin/lessons.controller.js +++ b/controllers/admin/lessons.controller.js @@ -66,6 +66,19 @@ const LESSON_LIST_COMPUTED = [ WHERE ul.lesson_id = "Lesson"."lesson_id" )`, }, + { + key: "course_bound", + label: "Course Status", + type: "boolean", + literal: `( + SELECT EXISTS ( + SELECT 1 + FROM unit_lessons ul + JOIN course_units cu ON cu.unit_id = ul.unit_id + WHERE ul.lesson_id = "Lesson"."lesson_id" + ) + )`, + }, ]; // ══════════════════════════════════════════════════════════════════════════════ @@ -101,6 +114,11 @@ exports.getLessonsFlat = async (req, res) => { WHERE ul.lesson_id = l.lesson_id) AS unit_count FROM lessons l WHERE l."deletedAt" IS NULL + AND NOT EXISTS ( + SELECT 1 FROM unit_lessons ul2 + JOIN course_units cu ON cu.unit_id = ul2.unit_id + WHERE ul2.lesson_id = l.lesson_id + ) ORDER BY l.title ASC `, { type: sequelize.QueryTypes.SELECT }); return R.success(res, "Lessons retrieved.", rows); diff --git a/controllers/admin/notification.controller.js b/controllers/admin/notification.controller.js index feadc09..f986402 100644 --- a/controllers/admin/notification.controller.js +++ b/controllers/admin/notification.controller.js @@ -24,6 +24,7 @@ async function list(req, res) { order: [['createdAt', 'DESC']], limit, offset, + where: { show_in_notifications: true }, }); return R.success(res, 'Notifications fetched.', { @@ -39,7 +40,7 @@ async function list(req, res) { // ─── GET /admin/notifications/unseen ───────────────────────────────────────── async function unseenCount(req, res) { try { - const count = await AdminNotification.count({ where: { seen: false } }); + const count = await AdminNotification.count({ where: { seen: false, show_in_notifications: true } }); return R.success(res, 'Unseen count fetched.', { count }); } catch (err) { console.error('[NOTIFICATION] unseenCount error:', err); diff --git a/controllers/admin/notificationBroadcasts.controller.js b/controllers/admin/notificationBroadcasts.controller.js index 408dbc8..8055bd1 100644 --- a/controllers/admin/notificationBroadcasts.controller.js +++ b/controllers/admin/notificationBroadcasts.controller.js @@ -32,6 +32,9 @@ async function applyBroadcastFields(broadcast, body) { if (body.title !== undefined) broadcast.title = body.title; if (body.message !== undefined) broadcast.message = body.message; + if (body.show_in_sticky !== undefined) broadcast.show_in_sticky = !!body.show_in_sticky; + if (body.show_in_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications; + if (body.target_type !== undefined) { if (!ALLOWED_TARGET_TYPES.includes(body.target_type)) { const err = new Error(`Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`); @@ -101,10 +104,10 @@ exports.getBroadcasts = async (req, res) => { if (Array.isArray(result?.data)) await attachTargetLabels(result.data); - return R.success(res, "Notification broadcasts retrieved.", result); + return R.success(res, "Announcements retrieved.", result); } catch (err) { console.error("[NOTIFICATION BROADCAST][GET ALL]", err); - return R.error(res, "Could not retrieve notification broadcasts.", 500); + return R.error(res, "Could not retrieve announcements.", 500); } }; @@ -123,7 +126,7 @@ exports.getBroadcast = async (req, res) => { ], }); - if (!broadcast) return R.error(res, "Notification broadcast not found.", 404); + if (!broadcast) return R.error(res, "Announcement not found.", 404); const json = broadcast.toJSON(); @@ -142,7 +145,7 @@ exports.getBroadcast = async (req, res) => { await attachTargetLabels(json); - return R.success(res, "Notification broadcast retrieved.", { data: json }); + return R.success(res, "Announcement retrieved.", { data: json }); } catch (err) { console.error("[NOTIFICATION BROADCAST][GET ONE]", err); return R.error(res, "Internal server error.", 500); @@ -153,7 +156,15 @@ exports.getBroadcast = async (req, res) => { exports.createBroadcast = async (req, res) => { try { - const { title, message, target_type, target_id, createdBy } = req.body; + const { + title, + message, + target_type, + target_id, + createdBy, + show_in_sticky, + show_in_notifications, + } = req.body; if (!title) return R.error(res, "title is required.", 400); if (!message) return R.error(res, "message is required.", 400); @@ -162,20 +173,31 @@ exports.createBroadcast = async (req, res) => { if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, "target_id is required for this target_type.", 400); if (!createdBy) return R.error(res, "createdBy is required.", 400); + const showSticky = show_in_sticky ?? false; + const showNotifs = show_in_notifications ?? true; + if (!showSticky && !showNotifs) { + return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400); + } + if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id); const t = await sequelize.transaction(); try { const broadcast = await NotificationBroadcast.build({ - title, message, createdBy, status: 'draft', + title, + message, + createdBy, + status: 'draft', target_type, target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null, + show_in_sticky: showSticky, + show_in_notifications: showNotifs, }); await broadcast.save({ transaction: t }); await t.commit(); logActivity(req.user?.user_id, 'create_notification_broadcast', { entityType: 'notification_broadcast', entityId: broadcast.broadcast_id, details: { target_type } }); - return R.success(res, "Notification broadcast created.", { data: broadcast }, 201); + return R.success(res, "Announcement created.", { data: broadcast }, 201); } catch (dbErr) { try { await t.rollback(); } catch { /* connection gone */ } throw dbErr; @@ -202,12 +224,17 @@ exports.updateBroadcast = async (req, res) => { const t = await sequelize.transaction(); try { await applyBroadcastFields(broadcast, req.body); + if (!broadcast.show_in_sticky && !broadcast.show_in_notifications) { + const err = new Error("At least one of show_in_sticky or show_in_notifications must be enabled."); + err.status = 400; + throw err; + } broadcast.updatedBy = req.body.updatedBy ?? null; await broadcast.save({ transaction: t }); await t.commit(); logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); - return R.success(res, "Notification broadcast updated.", { data: broadcast }); + return R.success(res, "Announcement updated.", { data: broadcast }); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } throw dbErr; @@ -227,7 +254,7 @@ exports.sendBroadcast = async (req, res) => { if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400); const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } }); - if (!broadcast) return R.error(res, "Notification broadcast not found.", 404); + if (!broadcast) return R.error(res, "Announcement not found.", 404); if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400); const t = await sequelize.transaction(); @@ -237,6 +264,8 @@ exports.sendBroadcast = async (req, res) => { const targetType = broadcast.target_type; const targetId = broadcast.target_id; + const showInSticky = !!broadcast.show_in_sticky; + const showInNotifications = !!broadcast.show_in_notifications; const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({ title: broadcast.title, @@ -246,7 +275,10 @@ exports.sendBroadcast = async (req, res) => { }); if (targetType === 'admin' || targetType === 'both') { - await AdminNotification.create({ ...baseNotify, seen: false }, { transaction: t }); + await AdminNotification.create( + { ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications }, + { transaction: t } + ); recipientCount += 1; } @@ -281,6 +313,8 @@ exports.sendBroadcast = async (req, res) => { seen: false, createdAt: now, updatedAt: now, + show_in_sticky: showInSticky, + show_in_notifications: showInNotifications, })), { validate: false, transaction: t } ); @@ -294,7 +328,7 @@ exports.sendBroadcast = async (req, res) => { await t.commit(); logActivity(req.user?.user_id, 'send_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId), details: { target_type: targetType, target_id: broadcast.target_id, recipient_count: recipientCount } }); - return R.success(res, "Notification broadcast sent.", { data: broadcast }); + return R.success(res, "Announcement sent.", { data: broadcast }); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } throw dbErr; @@ -314,12 +348,12 @@ exports.archiveBroadcast = async (req, res) => { if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400); const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } }); - if (!broadcast) return R.error(res, "Notification broadcast not found.", 404); + if (!broadcast) return R.error(res, "Announcement not found.", 404); await broadcast.update({ deletedBy: req.body.deletedBy ?? null }); await broadcast.destroy(); logActivity(req.user?.user_id, 'archive_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); - return R.success(res, "Notification broadcast archived."); + return R.success(res, "Announcement archived."); } catch (err) { console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err); return R.error(res, "Internal server error.", 500); @@ -359,13 +393,13 @@ exports.restoreBroadcast = async (req, res) => { const { broadcastId } = req.params; const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false }); - if (!broadcast) return R.error(res, "Notification broadcast not found.", 404); - if (!broadcast.deletedAt) return R.error(res, "Notification broadcast is not archived.", 400); + if (!broadcast) return R.error(res, "Announcement not found.", 404); + if (!broadcast.deletedAt) return R.error(res, "Announcement is not archived.", 400); await broadcast.restore(); await broadcast.update({ deletedBy: null }); logActivity(req.user?.user_id, 'restore_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); - return R.success(res, "Notification broadcast restored.", { data: broadcast }); + return R.success(res, "Announcement restored.", { data: broadcast }); } catch (err) { console.error("[NOTIFICATION BROADCAST][RESTORE]", err); return R.error(res, "Internal server error.", 500); @@ -413,10 +447,10 @@ exports.getArchivedBroadcasts = async (req, res) => { auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' }, findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }, }); - return R.success(res, "Archived notification broadcasts retrieved.", result); + return R.success(res, "Archived announcements retrieved.", result); } catch (err) { console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", err); - return R.error(res, "Could not retrieve archived notification broadcasts.", 500); + return R.error(res, "Could not retrieve archived announcements.", 500); } }; @@ -427,15 +461,15 @@ exports.permanentlyDeleteBroadcast = async (req, res) => { const { broadcastId } = req.params; const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false }); - if (!broadcast) return R.error(res, "Notification broadcast not found.", 404); - if (!broadcast.deletedAt) return R.error(res, "Notification broadcast must be archived before it can be permanently deleted.", 400); + if (!broadcast) return R.error(res, "Announcement not found.", 404); + if (!broadcast.deletedAt) return R.error(res, "Announcement must be archived before it can be permanently deleted.", 400); await broadcast.destroy({ force: true }); logActivity(req.user?.user_id, 'permanently_delete_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); - return R.success(res, "Notification broadcast permanently deleted."); + return R.success(res, "Announcement permanently deleted."); } catch (err) { console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", err); - return R.error(res, "Could not permanently delete notification broadcast.", 500); + return R.error(res, "Could not permanently delete announcement.", 500); } }; @@ -447,22 +481,22 @@ exports.permanentlyDeleteBroadcasts = async (req, res) => { if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false }); - if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404); + if (!broadcasts.length) return R.error(res, "No announcements found.", 404); const archived = broadcasts.filter((b) => b.deletedAt); - if (!archived.length) return R.error(res, "All selected notification broadcasts must be archived before they can be permanently deleted.", 400); + if (!archived.length) return R.error(res, "All selected announcements must be archived before they can be permanently deleted.", 400); const archivedIds = archived.map((b) => b.broadcast_id); await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: archivedIds } }, force: true }); logActivity(req.user?.user_id, 'bulk_permanently_delete_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } }); - return R.success(res, `${archivedIds.length} notification broadcast(s) permanently deleted.`, { + return R.success(res, `${archivedIds.length} announcement(s) permanently deleted.`, { deleted_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), }); } catch (err) { console.error("[NOTIFICATION BROADCAST][BULK PERMANENT DELETE]", err); - return R.error(res, "Could not permanently delete notification broadcasts.", 500); + return R.error(res, "Could not permanently delete announcements.", 500); } }; diff --git a/controllers/admin/notification_templates.controller.js b/controllers/admin/notification_templates.controller.js index 3052d97..3d07ea1 100644 --- a/controllers/admin/notification_templates.controller.js +++ b/controllers/admin/notification_templates.controller.js @@ -11,10 +11,10 @@ exports.getNotificationTemplates = async (req, res) => { const templates = await mdl_NotificationTemplate.findAll({ order: [['notify_type', 'ASC'], ['type', 'ASC']], }); - return R.success(res, 'Notification templates retrieved.', templates); + return R.success(res, 'Announcement templates retrieved.', templates); } catch (err) { console.error('[ADMIN][GET NOTIFICATION TEMPLATES]', err); - return R.error(res, 'Could not retrieve notification templates.', 500); + return R.error(res, 'Could not retrieve announcement templates.', 500); } }; @@ -23,11 +23,11 @@ exports.getNotificationTemplates = async (req, res) => { exports.getNotificationTemplate = async (req, res) => { try { const template = await mdl_NotificationTemplate.findByPk(req.params.id); - if (!template) return R.error(res, 'Notification template not found.', 404); - return R.success(res, 'Notification template retrieved.', template); + if (!template) return R.error(res, 'Announcement template not found.', 404); + return R.success(res, 'Announcement template retrieved.', template); } catch (err) { console.error('[ADMIN][GET NOTIFICATION TEMPLATE]', err); - return R.error(res, 'Could not retrieve notification template.', 500); + return R.error(res, 'Could not retrieve announcement template.', 500); } }; @@ -39,7 +39,7 @@ exports.getNotificationTemplate = async (req, res) => { exports.updateNotificationTemplate = async (req, res) => { try { const template = await mdl_NotificationTemplate.findByPk(req.params.id); - if (!template) return R.error(res, 'Notification template not found.', 404); + if (!template) return R.error(res, 'Announcement template not found.', 404); const { label, title, message, publish } = req.body; @@ -74,9 +74,9 @@ exports.updateNotificationTemplate = async (req, res) => { logActivity(req.user?.user_id, 'update_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { type: template.type, published: isPublishing } }); - return R.success(res, 'Notification template updated.', template); + return R.success(res, 'Announcement template updated.', template); } catch (err) { console.error('[ADMIN][UPDATE NOTIFICATION TEMPLATE]', err); - return R.error(res, 'Could not update notification template.', 500); + return R.error(res, 'Could not update announcement template.', 500); } }; diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index 64b62ac..254e574 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -26,6 +26,7 @@ const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util"); const { TaskCompletion } = require('../../models/task/task_completion.mdl'); const logActivity = require('../../utils/logActivity.util'); +const { nextOrderIndex, reorderJunction } = require('../../utils/courses/hierarchy.util'); // ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ── const normalizeUrl = (url) => { @@ -36,7 +37,7 @@ const normalizeUrl = (url) => { // ─── Allowed filter/sort fields ─────────────────────────────────────────────── const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt']; -const TASK_FIELDS = ['name', 'description', 'deadline', 'status', 'createdAt', 'updatedAt', 'deletedAt']; +const TASK_FIELDS = ['name', 'description', 'deadline', 'order_index', 'is_required', 'status', 'createdAt', 'updatedAt', 'deletedAt']; const FILTERABLE_MODELS = { TaskList: TaskList, Task: Task, @@ -420,6 +421,38 @@ exports.assignGroups = async (req, res) => { await t.commit(); logActivity(req.user.user_id, 'assign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: newIds } }); + + // ── Notify every member of the newly-assigned group(s) ───────────────── + if (newIds.length) { + try { + const taskCount = await Task.count({ where: { task_list_id: taskListId } }); + const memberRows = await mdl_UserGroupMembers.findAll({ + where: { group_id: newIds }, + attributes: ['user_id'], + }); + const seenUsers = new Set(); + const userIds = memberRows.filter(({ user_id }) => { + if (seenUsers.has(user_id)) return false; + seenUsers.add(user_id); + return true; + }).map((m) => m.user_id); + + if (userIds.length) { + const now = new Date(); + const notify = await renderNotification({ type: 'task_assigned', data: { + taskListName: taskList.name, + taskCount, + } }); + await UserNotification.bulkCreate( + userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })), + { validate: false } + ); + } + } catch (notifyErr) { + console.error('[ADMIN][ASSIGN GROUPS][NOTIFY]', notifyErr); + } + } + return R.success(res, `${newIds.length} group(s) assigned.`, { assigned_ids: newIds, already_assigned_ids: existingIds, @@ -556,7 +589,7 @@ exports.createTask = async (req, res) => { const t = await sequelize.transaction(); try { const { taskListId } = req.params; - const { name, description, deadline, requirements = [] } = req.body; + const { name, description, deadline, is_required, requirements = [] } = req.body; if (!name) return R.error(res, 'Task name is required.', 400); @@ -566,12 +599,16 @@ exports.createTask = async (req, res) => { return R.error(res, 'Task list not found.', 404); } + const order_index = await nextOrderIndex(Task, { task_list_id: taskListId }, t); + const task = await Task.create( { task_list_id: taskListId, name, description, deadline: deadline || null, + order_index, + is_required: is_required ?? true, createdBy: req.user.user_id, updatedBy: req.user.user_id, }, @@ -630,10 +667,10 @@ exports.updateTask = async (req, res) => { return R.error(res, 'Task not found.', 404); } - const { name, description, deadline, status, requirements } = req.body; + const { name, description, deadline, status, is_required, requirements } = req.body; await task.update( - { name, description, deadline: deadline || null, status, updatedBy: req.user.user_id }, + { name, description, deadline: deadline || null, status, is_required, updatedBy: req.user.user_id }, { transaction: t } ); @@ -757,6 +794,31 @@ exports.updateTask = async (req, res) => { } }; +// ─── REORDER ────────────────────────────────────────────────────────────────── +// PATCH /admin/task-lists/:taskListId/tasks/order { task_ids: [orderedIds] } + +exports.reorderTasks = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { taskListId } = req.params; + const { task_ids = [] } = req.body; + if (!task_ids.length) { await t.rollback(); return R.error(res, 'task_ids is required.', 400); } + + const taskList = await TaskList.findByPk(taskListId, { transaction: t }); + if (!taskList) { await t.rollback(); return R.error(res, 'Task list not found.', 404); } + + await reorderJunction(Task, 'task_list_id', taskListId, 'task_id', task_ids, t); + await t.commit(); + + logActivity(req.user.user_id, 'reorder_tasks', { entityType: 'task_list', entityId: taskListId, details: { task_ids } }); + return R.success(res, 'Task order updated.'); + } catch (err) { + await t.rollback(); + console.error('[ADMIN][REORDER TASKS]', err); + return R.error(res, 'Could not reorder tasks.', 500); + } +}; + // ─── GET ARCHIVED TASK LISTS ────────────────────────────────────────────────── exports.getArchivedTaskLists = async (req, res) => { diff --git a/controllers/admin/task_completion.controller.js b/controllers/admin/task_completion.controller.js index 5860b34..3eaa015 100644 --- a/controllers/admin/task_completion.controller.js +++ b/controllers/admin/task_completion.controller.js @@ -14,6 +14,9 @@ const sequelize = require('../../config/db.config'); const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl'); const { Task } = require('../../models/task/task.mdl'); const mdl_Users = require('../../models/users/users.mdl'); +const UserNotification = require('../../models/notifications/user_notification.mdl'); +const { renderNotification } = require('../../services/notificationTemplate.service'); +const { checkTaskCompletion, fireTaskCompletedEvent } = require('../client/task.controller'); const { adminExclude } = require('../../models/task/task_completion.attributes'); const R = require('../../utils/response.util'); @@ -140,6 +143,69 @@ exports.getCompletionsByUser = async (req, res) => { } }; +// ─── REVIEW ─────────────────────────────────────────────────────────────────── +// PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/review +// Approves/rejects a submission for a requirement flagged requires_review. +// Notifies the submitting learner via the existing template pattern (same shape +// as updateTask's task_requirements_updated notify block). + +exports.reviewSubmission = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { taskListId, taskId, completionId } = req.params; + const { status, review_note } = req.body; + + if (!['approved', 'rejected'].includes(status)) { + await t.rollback(); + return R.error(res, 'status must be "approved" or "rejected".', 400); + } + + const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t }); + if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); } + + const completion = await TaskCompletion.findOne({ + where: { completion_id: completionId, task_id: taskId }, + transaction: t, + }); + if (!completion) { await t.rollback(); return R.error(res, 'Completion not found.', 404); } + + const wasComplete = status === 'approved' ? await checkTaskCompletion(completion.user_id, taskId) : false; + + await completion.update({ + status, + review_note: review_note || null, + reviewed_by: req.user.user_id, + reviewed_at: new Date(), + updatedBy: req.user.user_id, + }, { transaction: t }); + + await t.commit(); + + if (status === 'approved' && !wasComplete && await checkTaskCompletion(completion.user_id, taskId)) { + fireTaskCompletedEvent(completion.user_id, taskId); // fire-and-forget + } + + logActivity(req.user.user_id, 'review_task_submission', { + entityType: 'task_completion', entityId: completionId, details: { task_id: taskId, status }, + }); + + try { + const notify = await renderNotification({ type: 'task_submission_reviewed', data: { + taskName: task.name, status, review_note: review_note || null, + } }); + await UserNotification.create({ user_id: completion.user_id, ...notify, seen: false }); + } catch (notifyErr) { + console.error('[ADMIN][REVIEW SUBMISSION][NOTIFY]', notifyErr); + } + + return R.success(res, 'Submission reviewed.', completion); + } catch (err) { + await t.rollback(); + console.error('[ADMIN][REVIEW SUBMISSION]', err); + return R.error(res, 'Could not review submission.', 500); + } +}; + // ─── ARCHIVE ────────────────────────────────────────────────────────────────── // DELETE /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js index b9e6a4e..4e7b41e 100644 --- a/controllers/admin/tiers.controller.js +++ b/controllers/admin/tiers.controller.js @@ -104,7 +104,7 @@ function computeDurationDays(value, unit) { exports.createPlan = async (req, res) => { try { - const { tier_category_id, label, description, duration_value, duration_unit = 'day', price, currency } = req.body; + const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency } = req.body; if (!tier_category_id || !label || !duration_value || !price) return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400); @@ -120,7 +120,7 @@ exports.createPlan = async (req, res) => { const plan = await mdl_TierPlans.create({ tier_category_id: category.tier_category_id, tier: category.slug, - label, description, duration_days, duration_unit, price, currency, + label, description, features, duration_days, duration_unit, price, currency, }); const plain = plan.get({ plain: true }); logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } }); @@ -136,7 +136,7 @@ exports.updatePlan = async (req, res) => { const plan = await mdl_TierPlans.findByPk(req.params.id); if (!plan) return R.error(res, 'Plan not found.', 404); - const allowed = ['label', 'description', 'price', 'currency', 'is_active', 'tier_category_id']; + const allowed = ['label', 'description', 'features', 'price', 'currency', 'is_active', 'tier_category_id']; const updates = {}; for (const k of allowed) { if (req.body[k] !== undefined) updates[k] = req.body[k]; diff --git a/controllers/admin/units.controller.js b/controllers/admin/units.controller.js index 863ca6d..a6d3ecf 100644 --- a/controllers/admin/units.controller.js +++ b/controllers/admin/units.controller.js @@ -153,11 +153,12 @@ exports.getUnit = async (req, res) => { exports.createUnit = async (req, res) => { const t = await sequelize.transaction(); try { - const { title, description, course_id, order, createdBy } = req.body; + const { title, description, subscription, course_id, order, createdBy } = req.body; if (!title) return R.error(res, "Title is required.", 400); const unit = await Unit.create({ title, + subscription: subscription || null, description: description ?? null, duration_seconds: 0, createdBy: createdBy ?? req.user?.user_id ?? null, @@ -196,10 +197,11 @@ exports.updateUnit = async (req, res) => { const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); - const { title, description, updatedBy } = req.body; + const { title, description, subscription, updatedBy } = req.body; - if (title !== undefined) unit.title = title; - if (description !== undefined) unit.description = description; + if (title !== undefined) unit.title = title; + if (description !== undefined) unit.description = description; + if (subscription !== undefined) unit.subscription = subscription || null; unit.updatedBy = updatedBy ?? req.user?.user_id ?? null; await unit.save(); diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index 75f1be1..10fa735 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -19,6 +19,9 @@ const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl"); const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl"); const mdl_Product = require("../../models/courses/products.mdl"); const mdl_Category = require("../../models/courses/categories.mdl"); +const mdl_PlanPolicy = require("../../models/tiers/plan_policies.mdl"); +const { mdl_UserGroupMembers } = require("../../models/users/user_groups.mdl"); +const { evaluateCourseAccess } = require("../../utils/accessPolicy.util"); const { Course, @@ -79,7 +82,9 @@ async function expireSession(session, passingScore) { return expiredAttempt; } -// Builds minimal user context: active tier slug + live tier rank map +// Builds user context for evaluateCourseAccess: active tier slug, live tier +// rank map, the active plan's access_rules (if any), and group memberships +// (needed for the group_restriction rule type). async function buildUserContext(user_id) { const activeTier = await getActiveTier(user_id); const tier = activeTier?.tier ?? 'free'; @@ -88,7 +93,19 @@ async function buildUserContext(user_id) { const tierRankMap = {}; for (const c of categories) tierRankMap[c.slug] = c.rank; - return { tier, tierRankMap }; + let access_rules = []; + if (activeTier?.plan_id) { + const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: activeTier.plan_id } }); + access_rules = policy?.access_rules ?? []; + } + + const memberships = await mdl_UserGroupMembers.findAll({ + where: { user_id, deletedAt: null }, + attributes: ['group_id'], + }); + const group_ids = memberships.map((m) => m.group_id); + + return { tier, tierRankMap, access_rules, group_ids }; } // ─── Shared tier + purchase access check ───────────────────────────────────── @@ -96,16 +113,15 @@ async function buildUserContext(user_id) { // Returns false → user's tier is too low AND no valid individual purchase. async function canAccessCourse(user_id, course_id) { const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] }); - const requiredTier = course?.subscription ?? 'free'; + if (!course) return false; const userCtx = await buildUserContext(user_id); - // Rank-0 slugs (default/free tier) are always accessible — resolved dynamically - const courseRank = userCtx.tierRankMap[requiredTier] ?? Infinity; - if (courseRank === 0) return true; - - const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0; - if (userRank >= courseRank) return true; + // evaluateCourseAccess already falls back to plain rank comparison when the + // active plan has no access_rules configured — same behavior as before for + // every course/plan combination that hasn't opted into the richer engine. + const { allowed } = evaluateCourseAccess(userCtx, course, userCtx.tierRankMap); + if (allowed) return true; // Individual purchase as fallback const product = await mdl_Product.findOne({ where: { course_id } }); @@ -129,8 +145,19 @@ async function canAccessCourse(user_id, course_id) { // content locked while letting genuinely standalone content run independently. async function canAccessUnit(user_id, unit_id) { + // A unit's own subscription (standalone tier-gating) is an additional, + // OR'd access path alongside any attached course's access — most + // standalone units have zero course links anyway, but a unit that somehow + // has both should be unlockable via either. + const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] }); + if (unit?.subscription) { + const userCtx = await buildUserContext(user_id); + const { allowed } = evaluateCourseAccess(userCtx, { subscription: unit.subscription }, userCtx.tierRankMap); + if (allowed) return true; + } + const links = await CourseUnit.findAll({ where: { unit_id }, attributes: ['course_id'] }); - if (!links.length) return true; + if (!links.length) return !unit?.subscription; for (const link of links) { if (await canAccessCourse(user_id, link.course_id)) return true; } @@ -1083,6 +1110,52 @@ exports.getUnitByUuid = async (req, res) => { } }; +// "Quiz (self-enrich for pass_quiz task requirement blocks)" — mirrors +// getUnitByUuid/getLessonByUuid's uuid-lookup pattern. A quiz is always +// unit-scoped (unit_quizzes.unit_id unique) so access resolves through its +// one parent unit, same rule canAccessUnit already implements. +exports.getQuizByUuid = async (req, res) => { + try { + const { uuid } = req.params; + const quiz = await UnitQuiz.findOne({ + where: { uuid, ...notDeleted }, + attributes: ["quiz_id", "uuid", "title", "is_required", "passing_score"], + include: [{ + model: Unit, as: "unit", + attributes: ["unit_id", "uuid", "title"], + include: [{ + model: Course, as: "courses", + where: notDeleted, required: false, + attributes: ["course_id", "title", "subscription"], + through: { attributes: [] }, + }], + }], + }); + if (!quiz) return R.error(res, "Quiz not found.", 404); + + if (!await canAccessUnit(req.user.user_id, quiz.unit.unit_id)) { + const first = quiz.unit.courses?.[0] ?? null; + return res.status(403).json({ + status: "error", + message: "You do not have access to this quiz.", + course: first ? { title: first.title, subscription: first.subscription } : null, + }); + } + + const passedAttempt = await QuizAttempt.findOne({ + where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, passed: true }, + }); + + const plain = quiz.toJSON(); + plain.unit.course = plain.unit.courses?.[0] ?? null; // back-compat singular field + plain.has_passed = !!passedAttempt; + return R.success(res, "Quiz retrieved.", plain); + } catch (err) { + console.error("[CLIENT][QUIZ][BY UUID]", err); + return R.error(res, "Could not retrieve quiz.", 500); + } +}; + // "Units → Lessons (returns all data)" — a Unit resolves all of its lesson // content in one call, with or without a parent course. exports.getLessonsByUnitUuid = async (req, res) => { @@ -1105,7 +1178,10 @@ exports.getLessonsByUnitUuid = async (req, res) => { required: false, attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"], through: { attributes: ["order_index"] }, - include: [{ model: LessonPage, as: "page", attributes: ["blocks"], required: false }], + include: [ + { model: LessonPage, as: "page", attributes: ["blocks"], required: false }, + { model: LessonObjective, as: "objectives", required: false, attributes: ["objective_id", "text", "order_index"] }, + ], }, { model: UnitQuiz, as: "quiz", @@ -1147,6 +1223,7 @@ exports.getLessonsByUnitUuid = async (req, res) => { order_index: l.order_index ?? 0, duration_seconds: l.duration_seconds ?? 0, blocks: l.page?.blocks ?? [], + objectives: (l.objectives ?? []).slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)), status: progressMap.get(String(l.lesson_id))?.status ?? "not_started", completed_at: progressMap.get(String(l.lesson_id))?.completed_at ?? null, })); @@ -1187,7 +1264,7 @@ exports.getLessonByUuid = async (req, res) => { const { uuid } = req.params; const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, - attributes: ["lesson_id", "uuid", "title", "description"], + attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"], include: [ { model: LessonPage, @@ -1195,6 +1272,12 @@ exports.getLessonByUuid = async (req, res) => { attributes: ["blocks"], required: false, }, + { + model: LessonObjective, + as: "objectives", + required: false, + attributes: ["objective_id", "text", "order_index"], + }, { model: Unit, as: "units", @@ -1209,6 +1292,7 @@ exports.getLessonByUuid = async (req, res) => { }], }, ], + order: [[{ model: LessonObjective, as: "objectives" }, "order_index", "ASC"]], }); if (!lesson) return R.error(res, "Lesson not found.", 404); @@ -1221,6 +1305,11 @@ exports.getLessonByUuid = async (req, res) => { }); } + const progress = await LessonReadingProgress.findOne({ + where: { user_id: req.user.user_id, lesson_id: lesson.lesson_id }, + attributes: ["status", "completed_at"], + }); + const plain = lesson.toJSON(); const firstUnit = plain.units?.[0] ?? null; const data = { @@ -1228,8 +1317,12 @@ exports.getLessonByUuid = async (req, res) => { uuid: plain.uuid, title: plain.title, description: plain.description, + duration_seconds: plain.duration_seconds ?? 0, blocks: plain.page?.blocks ?? [], - unit: firstUnit ? { unit_id: firstUnit.unit_id, title: firstUnit.title, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field + objectives: plain.objectives ?? [], + status: progress?.status ?? "not_started", + completed_at: progress?.completed_at ?? null, + unit: firstUnit ? { unit_id: firstUnit.unit_id, uuid: firstUnit.uuid, title: firstUnit.title, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field units: plain.units ?? [], }; return R.success(res, "Lesson retrieved.", data); diff --git a/controllers/client/media.controller.js b/controllers/client/media.controller.js index dd942b7..d6321c8 100644 --- a/controllers/client/media.controller.js +++ b/controllers/client/media.controller.js @@ -260,7 +260,8 @@ exports.streamAsset = async (req, res) => { let presignedUrl; try { presignedUrl = await s3.getSignedDownloadUrl(storage_key, 60); - console.log("Presigned URL:", presignedUrl); + // ── Just comment out for debug if S3_ENDPOINT is undefined ──────────────── + // console.log("Presigned URL:", presignedUrl); } catch (err) { console.error("[CLIENT][MEDIA][STREAM] Pre-sign failed:", err.message); return res.status(500).json({ message: "Could not resolve media stream." }); diff --git a/controllers/client/notification.controller.js b/controllers/client/notification.controller.js index 4dc0086..a7eaabc 100644 --- a/controllers/client/notification.controller.js +++ b/controllers/client/notification.controller.js @@ -23,7 +23,7 @@ async function list(req, res) { const offset = (page - 1) * limit; const { count, rows } = await UserNotification.findAndCountAll({ - where: { user_id: userId }, + where: { user_id: userId, show_in_notifications: true }, order: [['createdAt', 'DESC']], limit, offset, @@ -44,7 +44,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 }, + where: { user_id: req.user.user_id, seen: false, show_in_notifications: true }, }); return R.success(res, 'Unseen count fetched.', { count }); } catch (err) { @@ -53,6 +53,28 @@ 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"]], + }); + + return R.success(res, "Sticky announcement fetched.", { + announcement: notification, + }); + } catch (err) { + console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err); + return R.error(res, "Failed to fetch sticky announcement."); + } +} + // ─── PATCH /client/notifications/:id/seen ──────────────────────────────────── async function markSeen(req, res) { try { @@ -97,4 +119,4 @@ async function clearAll(req, res) { } } -module.exports = { list, unseenCount, markSeen, markAllSeen, clearAll }; +module.exports = { list, unseenCount, stickyAnnouncement, markSeen, markAllSeen, clearAll }; diff --git a/controllers/client/task.controller.js b/controllers/client/task.controller.js index 53a8af1..0a1cf95 100644 --- a/controllers/client/task.controller.js +++ b/controllers/client/task.controller.js @@ -17,12 +17,16 @@ const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_c const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const mdl_Users = require('../../models/users/users.mdl'); +const { QuizAttempt } = require('../../models/courses/courses.associations'); 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 UserNotification = require('../../models/notifications/user_notification.mdl'); +const { renderNotification } = require('../../services/notificationTemplate.service'); +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; const isUUID = (v) => UUID_RE.test(v); @@ -129,6 +133,150 @@ const isMember = async (userId, groupId) => { return !!membership; }; +// ─── Helper: per-user completion signals for a batch of tasks ───────────────── +// Shared by getGroupTaskList/getGroupTaskLists. upload_file/submit_text share +// one TaskCompletion per task (resubmit-anytime — latest by submitted_at wins); +// pass_quiz is computed live from QuizAttempt, same as unit-quiz has_passed +// (courses.controller.js) rather than a separately-synced TaskProgress row. +const getTaskCompletionSignals = async (userId, taskIds, requirements) => { + const quizIds = [...new Set( + requirements.filter((r) => r.type === 'pass_quiz' && r.reference_id).map((r) => r.reference_id) + )]; + + const [completions, linkVisits, progressRows, passedAttempts] = await Promise.all([ + taskIds.length + ? TaskCompletion.findAll({ + where: { task_id: { [Op.in]: taskIds }, user_id: userId }, + attributes: ['task_id', 'status', 'submitted_at'], + order: [['submitted_at', 'DESC']], + }) + : [], + taskIds.length + ? TaskLinkVisit.findAll({ + where: { task_id: { [Op.in]: taskIds }, user_id: userId }, + attributes: ['task_id', 'requirement_id'], + }) + : [], + taskIds.length + ? TaskProgress.findAll({ + where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true }, + attributes: ['task_id', 'requirement_id', 'reference_id'], + }) + : [], + quizIds.length + ? QuizAttempt.findAll({ + where: { quiz_id: { [Op.in]: quizIds }, user_id: userId, passed: true }, + attributes: ['quiz_id'], + }) + : [], + ]); + + // First row per task_id wins — completions are ordered submitted_at DESC. + const latestCompletionByTask = new Map(); + for (const c of completions) { + if (!latestCompletionByTask.has(c.task_id)) latestCompletionByTask.set(c.task_id, c); + } + + return { + latestCompletionByTask, + visitedRequirementIds: new Set(linkVisits.map((v) => v.requirement_id)), + completedProgressKeys: new Set(progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)), + passedQuizIds: new Set(passedAttempts.map((a) => String(a.quiz_id))), + }; +}; + +// ─── Helper: has this requirement been satisfied by the user? ───────────────── +const isRequirementDone = (r, signals) => { + switch (r.type) { + case 'upload_file': + case 'submit_text': { + const completion = signals.latestCompletionByTask.get(r.task_id); + if (!completion) return false; + return r.requires_review ? completion.status === 'approved' : true; + } + case 'visit_link': + return signals.visitedRequirementIds.has(r.requirement_id); + case 'read_course': + case 'read_unit': + case 'read_lesson': + return signals.completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`); + case 'pass_quiz': + return signals.passedQuizIds.has(String(r.reference_id)); + default: + return true; // unknown requirement types don't block completion + } +}; + +// ─── Helper: server-side sequencing gate ─────────────────────────────────── +// Rejects a completion write if any earlier *required* task in the same list +// isn't complete yet — the enforcement piece the unit-quiz sequencing +// precedent (UnitList.jsx) does NOT have (that one is client-lock-only). +// Shared by this file's submitTask and task_progress.controller.js's +// visitLink/updateProgress. +const assertTaskUnlocked = async (userId, taskListId, orderIndex) => { + const earlierRequired = await Task.findAll({ + where: { task_list_id: taskListId, order_index: { [Op.lt]: orderIndex }, is_required: true }, + include: [{ model: TaskRequirement, as: 'requirements' }], + }); + if (!earlierRequired.length) return true; + + const taskIds = earlierRequired.map((t) => t.task_id); + const allRequirements = earlierRequired.flatMap((t) => (t.requirements ?? []).map((r) => r.toJSON())); + const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements); + + return earlierRequired.every((t) => { + const reqs = (t.requirements ?? []); + return reqs.length > 0 && reqs.every((r) => isRequirementDone(r.toJSON ? r.toJSON() : r, signals)); + }); +}; + +// ─── Helper: is this one task fully done for this user, right now? ───────── +const checkTaskCompletion = async (userId, taskId) => { + const reqs = await TaskRequirement.findAll({ where: { task_id: taskId } }); + if (!reqs.length) return false; + const plainReqs = reqs.map((r) => r.toJSON()); + const signals = await getTaskCompletionSignals(userId, [taskId], plainReqs); + return plainReqs.every((r) => isRequirementDone(r, signals)); +}; + +// ─── Helper: fire task_completed (+ task_list_finisher achievement) on the +// 0→1 completion transition. Callers compute `wasComplete` themselves right +// before their write, then call this after, so it only fires once per task. +const fireTaskCompletedEvent = async (userId, taskId) => { + try { + const task = await Task.findByPk(taskId); + if (!task) return; + + try { + const notify = await renderNotification({ type: 'task_completed', data: { taskName: task.name } }); + await UserNotification.create({ user_id: userId, ...notify, seen: false }); + } catch (notifyErr) { + console.error('[TASK][NOTIFY COMPLETED]', notifyErr); + } + + await onTaskCompleted(userId, taskId, task.name); + + // ── Whole-list completion — check every sibling task too ─────────── + const siblingTasks = await Task.findAll({ where: { task_list_id: task.task_list_id } }); + const allDone = siblingTasks.length > 0 && ( + await Promise.all(siblingTasks.map((t) => checkTaskCompletion(userId, t.task_id))) + ).every(Boolean); + + if (allDone) { + const taskList = await TaskList.findByPk(task.task_list_id); + if (taskList) await onTaskListCompleted(userId, taskList.task_list_id, taskList.name); + } + } catch (err) { + console.error('[TASK][FIRE COMPLETED EVENT]', err); + } +}; + +exports.getTaskCompletionSignals = getTaskCompletionSignals; +exports.isRequirementDone = isRequirementDone; +exports.assertTaskUnlocked = assertTaskUnlocked; +exports.checkTaskCompletion = checkTaskCompletion; +exports.fireTaskCompletedEvent = fireTaskCompletedEvent; + // ───────────────────────────────────────────────────────────────────────────── // REPLACEMENT: getGroupTaskList in task.controller.js (client) // @@ -185,7 +333,7 @@ exports.getGroupTaskList = async (req, res) => { attributes: { exclude: userExclude }, order: [['order', 'ASC']], }], - order: [['createdAt', 'ASC']], + order: [['order_index', 'ASC']], }, ], }); @@ -204,35 +352,8 @@ exports.getGroupTaskList = async (req, res) => { await hydrateReadTaskProgress(userId, readRequirements); // ── Fetch user's completion signals for these tasks ──────────────────── - const [completions, linkVisits, progressRows] = await Promise.all([ - taskIds.length - ? TaskCompletion.findAll({ - where: { task_id: { [Op.in]: taskIds }, user_id: userId }, - attributes: ['task_id'], - }) - : [], - taskIds.length - ? TaskLinkVisit.findAll({ - where: { task_id: { [Op.in]: taskIds }, user_id: userId }, - attributes: ['task_id', 'requirement_id'], - }) - : [], - taskIds.length - ? TaskProgress.findAll({ - where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true }, - attributes: ['task_id', 'requirement_id', 'reference_id'], - }) - : [], - ]); - - // ── Lookup sets/maps for per-requirement checks ───────────────────────── - const tasksWithCompletion = new Set(completions.map((c) => c.task_id)); - - const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id)); - - const completedProgressKeys = new Set( - progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`) - ); + const allRequirements = tasks.flatMap((task) => task.requirements ?? []); + const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements); const now = Date.now(); @@ -240,20 +361,8 @@ exports.getGroupTaskList = async (req, res) => { const bucketedTasks = tasks.map((task) => { const requirements = task.requirements ?? []; - const allRequirementsDone = requirements.length > 0 && requirements.every((r) => { - switch (r.type) { - case 'upload_file': - return tasksWithCompletion.has(task.task_id); - case 'visit_link': - return visitedRequirementIds.has(r.requirement_id); - case 'read_course': - case 'read_unit': - case 'read_lesson': - return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`); - default: - return true; // unknown requirement types don't block completion - } - }); + const allRequirementsDone = requirements.length > 0 && + requirements.every((r) => isRequirementDone(r, signals)); const has_completed = allRequirementsDone; @@ -333,7 +442,7 @@ exports.getGroupTaskLists = async (req, res) => { order: [['order', 'ASC']], }, ], - order: [['createdAt', 'ASC']], + order: [['order_index', 'ASC']], }, ], attributes: { exclude: userExclude }, @@ -357,33 +466,8 @@ exports.getGroupTaskLists = async (req, res) => { await hydrateReadTaskProgress(userId, readRequirements); // ── Fetch user's completion signals for these tasks ──────────────────── - const [completions, linkVisits, progressRows] = await Promise.all([ - taskIds.length - ? TaskCompletion.findAll({ - where: { task_id: { [Op.in]: taskIds }, user_id: userId }, - attributes: ['task_id'], - }) - : [], - taskIds.length - ? TaskLinkVisit.findAll({ - where: { task_id: { [Op.in]: taskIds }, user_id: userId }, - attributes: ['task_id', 'requirement_id'], - }) - : [], - taskIds.length - ? TaskProgress.findAll({ - where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true }, - attributes: ['task_id', 'requirement_id', 'reference_id'], - }) - : [], - ]); - - // ── Lookup sets/maps for per-requirement checks ───────────────────────── - const tasksWithCompletion = new Set(completions.map((c) => c.task_id)); - const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id)); - const completedProgressKeys = new Set( - progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`) - ); + const allRequirements = allTasks.flatMap((task) => task.requirements ?? []); + const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements); const now = Date.now(); @@ -392,20 +476,7 @@ exports.getGroupTaskLists = async (req, res) => { const requirements = task.requirements ?? []; if (requirements.length === 0) return false; // vacuously not done - return requirements.every((r) => { - switch (r.type) { - case 'upload_file': - return tasksWithCompletion.has(task.task_id); - case 'visit_link': - return visitedRequirementIds.has(r.requirement_id); - case 'read_course': - case 'read_unit': - case 'read_lesson': - return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`); - default: - return true; - } - }); + return requirements.every((r) => isRequirementDone(r, signals)); }; // ── Bucket each task list based on per-task has_completed ─────────────── @@ -576,7 +647,7 @@ exports.submitTask = async (req, res) => { const t = await sequelize.transaction(); try { const { groupId, taskListId, taskId } = req.params; - const { note, files = [] } = req.body; + const { note, files = [], response_text } = req.body; const member = await isMember(req.user.user_id, groupId); if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); } @@ -584,10 +655,33 @@ exports.submitTask = async (req, res) => { const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t }); if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); } - if (!files.length) { + if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index))) { + await t.rollback(); + return R.error(res, 'Complete the earlier required tasks in this list first.', 409); + } + + const wasComplete = await checkTaskCompletion(req.user.user_id, taskId); + + // ── Which submission-based requirement(s) does this task have? ───────── + const submissionRequirements = await TaskRequirement.findAll({ + where: { task_id: taskId, type: { [Op.in]: ['upload_file', 'submit_text'] } }, + transaction: t, + }); + const uploadRequirement = submissionRequirements.find((r) => r.type === 'upload_file'); + const textRequirement = submissionRequirements.find((r) => r.type === 'submit_text'); + + if (!uploadRequirement && !textRequirement) { + await t.rollback(); + return R.error(res, 'This task has no requirement that accepts a submission.', 400); + } + if (uploadRequirement && !files.length) { await t.rollback(); return R.error(res, 'At least one file is required to submit.', 400); } + if (!uploadRequirement && textRequirement && !(response_text ?? '').trim()) { + await t.rollback(); + return R.error(res, 'A response is required to submit.', 400); + } // Validate file entries have required fields const invalid = files.some((f) => !f.file_url || !f.file_name); @@ -596,12 +690,6 @@ exports.submitTask = async (req, res) => { return R.error(res, 'Each file must have file_url and file_name.', 400); } - // ── Validate against upload_file requirement (if defined) ────────────── - const uploadRequirement = await TaskRequirement.findOne({ - where: { task_id: taskId, type: 'upload_file' }, - transaction: t, - }); - if (uploadRequirement) { // ── max_file_count ─────────────────────────────────────────────────── const maxFiles = uploadRequirement.max_file_count; @@ -641,22 +729,25 @@ exports.submitTask = async (req, res) => { task_id: taskId, user_id: req.user.user_id, note: note || null, + response_text: response_text || null, submitted_at: new Date(), createdBy: req.user.user_id, updatedBy: req.user.user_id, }, { transaction: t }); - const fileRows = files.map((f) => ({ - completion_id: completion.completion_id, - file_url: f.file_url, - file_name: f.file_name, - file_size: f.file_size ?? null, - mime_type: f.mime_type ?? null, - storage_key: f.storage_key ?? null, - createdBy: req.user.user_id, - updatedBy: req.user.user_id, - })); - await TaskCompletionFile.bulkCreate(fileRows, { transaction: t }); + if (files.length) { + const fileRows = files.map((f) => ({ + completion_id: completion.completion_id, + file_url: f.file_url, + file_name: f.file_name, + file_size: f.file_size ?? null, + mime_type: f.mime_type ?? null, + storage_key: f.storage_key ?? null, + createdBy: req.user.user_id, + updatedBy: req.user.user_id, + })); + await TaskCompletionFile.bulkCreate(fileRows, { transaction: t }); + } await t.commit(); @@ -675,6 +766,10 @@ exports.submitTask = async (req, res) => { entityId: Number(taskId), }); + if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) { + fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget + } + return R.success(res, 'Task submitted successfully.', full, 201); } catch (err) { await t.rollback(); diff --git a/controllers/client/task_progress.controller.js b/controllers/client/task_progress.controller.js index 71751dd..9f5d025 100644 --- a/controllers/client/task_progress.controller.js +++ b/controllers/client/task_progress.controller.js @@ -25,11 +25,13 @@ const { Op } = require('sequelize'); const sequelize = require('../../config/db.config'); const { Task, TaskRequirement } = require('../../models/task/task.mdl'); +const { assertTaskUnlocked, checkTaskCompletion, fireTaskCompletedEvent } = require('./task.controller'); const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl'); const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { Course } = require('../../models/courses/courses.mdl'); const CourseAssessment = require('../../models/courses/course_assessment.mdl'); const QuizAttempt = require('../../models/courses/quiz_attempt.mdl'); +const UnitQuiz = require('../../models/courses/unit_quiz.mdl'); const logActivity = require('../../utils/logActivity.util'); const R = require('../../utils/response.util'); const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service'); @@ -139,6 +141,35 @@ exports.getTaskProgress = async (req, res) => { await hydrateReadTaskProgress(req.user.user_id, readRequirements); + // pass_quiz completion is computed live from QuizAttempt (same source + // has_passed already uses for unit quizzes) rather than a synced + // TaskProgress row — reference_id on the requirement is the quiz's + // uuid, so resolve to quiz_id first. + const quizRequirements = await TaskRequirement.findAll({ + where: { task_id: taskId, type: 'pass_quiz' }, + attributes: ['requirement_id', 'reference_id'], + }); + const quizPassedRows = []; + if (quizRequirements.length) { + const quizUuids = [...new Set(quizRequirements.map((r) => r.reference_id).filter(Boolean))]; + const quizzes = await UnitQuiz.findAll({ where: { uuid: quizUuids }, attributes: ['quiz_id', 'uuid'] }); + const quizIdByUuid = new Map(quizzes.map((q) => [q.uuid, q.quiz_id])); + const quizIds = quizzes.map((q) => q.quiz_id); + const passedAttempts = quizIds.length + ? await QuizAttempt.findAll({ where: { quiz_id: { [Op.in]: quizIds }, user_id: req.user.user_id, passed: true }, attributes: ['quiz_id'] }) + : []; + const passedQuizIds = new Set(passedAttempts.map((a) => String(a.quiz_id))); + for (const r of quizRequirements) { + const quizId = quizIdByUuid.get(r.reference_id); + quizPassedRows.push({ + requirement_id: r.requirement_id, + reference_id: r.reference_id, + completed: quizId ? passedQuizIds.has(String(quizId)) : false, + completed_at: null, + }); + } + } + const [linkVisits, progress] = await Promise.all([ TaskLinkVisit.findAll({ where: { task_id: taskId, user_id: req.user.user_id }, @@ -150,7 +181,10 @@ exports.getTaskProgress = async (req, res) => { }), ]); - return R.success(res, 'Task progress retrieved.', { link_visits: linkVisits, progress }); + return R.success(res, 'Task progress retrieved.', { + link_visits: linkVisits, + progress: [...progress, ...quizPassedRows], + }); } catch (err) { console.error('[CLIENT][GET TASK PROGRESS]', err); return R.error(res, 'Could not retrieve task progress.', 500); @@ -182,6 +216,15 @@ exports.visitLink = async (req, res) => { return R.error(res, 'Requirement is not a visit_link type.', 400); } + const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t }); + if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); } + if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index))) { + await t.rollback(); + return R.error(res, 'Complete the earlier required tasks in this list first.', 409); + } + + const wasComplete = await checkTaskCompletion(req.user.user_id, taskId); + const now = new Date(); const [record, created] = await TaskLinkVisit.upsert( @@ -210,6 +253,10 @@ exports.visitLink = async (req, res) => { }); } + if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) { + fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget + } + return R.success( res, created ? 'Link visited.' : 'Link visit updated.', @@ -302,6 +349,7 @@ exports.updateProgress = async (req, res) => { const now = new Date(); const userId = req.user.user_id; + const wasComplete = await checkTaskCompletion(userId, taskId); // ── Direct UPSERT for read_unit / read_course ───────────────────────── if (requirement.type === 'read_unit' || requirement.type === 'read_course') { @@ -323,6 +371,9 @@ exports.updateProgress = async (req, res) => { } ); await t.commit(); + if (!wasComplete && await checkTaskCompletion(userId, taskId)) { + fireTaskCompletedEvent(userId, taskId); // fire-and-forget + } return R.success(res, 'Progress updated.', { requirement_id: requirementId, reference_id, @@ -403,6 +454,9 @@ exports.updateProgress = async (req, res) => { } await t.commit(); + if (!wasComplete && await checkTaskCompletion(userId, taskId)) { + fireTaskCompletedEvent(userId, taskId); // fire-and-forget + } return R.success(res, 'Progress updated.', { requirement_id: requirementId, reference_id, diff --git a/controllers/client/tiers.controller.js b/controllers/client/tiers.controller.js index 34e0b3a..e86957c 100644 --- a/controllers/client/tiers.controller.js +++ b/controllers/client/tiers.controller.js @@ -105,7 +105,7 @@ exports.getPlans = async (req, res) => { try { const plans = await mdl_TierPlans.findAll({ order: [['tier', 'ASC'], ['duration_days', 'ASC']], - attributes: ['plan_id', 'tier', 'label', 'description', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'], + attributes: ['plan_id', 'tier', 'label', 'description', 'features', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'], include: [ { model: Course, diff --git a/controllers/client/units.controller.js b/controllers/client/units.controller.js index 2401855..d85dd9b 100644 --- a/controllers/client/units.controller.js +++ b/controllers/client/units.controller.js @@ -4,7 +4,8 @@ * Description: Standalone Unit / Lesson consumption — the junction revamp lets * learners run Units and Lessons outside any Course: * - * GET /client/units → all units w/ lesson counts + is_locked + * GET /client/units → INDEPENDENT units only (no course affiliation) + * GET /client/lessons → INDEPENDENT lessons only (no unit is course-affiliated) * GET /client/units/:uuid → unit metadata (shared handler) * GET /client/units/:uuid/lessons → unit + ALL lesson data (shared handler) * GET /client/units/:uuid/quiz → the unit's quiz, no course context @@ -16,6 +17,13 @@ * be able to access at least one attached course. Lessons resolve through * their parent units the same way. * + * Discovery rule (getUnits/getLessons only): only course-free content is + * listed at all — a unit with any course affiliation, or a lesson with any + * unit that has a course affiliation, is excluded outright rather than + * listed-but-locked. This does not affect the single-item endpoints above + * (:uuid) — those still enforce access normally for direct links, and + * course-scoped consumption runs through a separate controller entirely. + * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jul. 7, 2026 ***********************************************************************************************************************************************************************/ @@ -53,11 +61,17 @@ function sanitizeQuestions(questions = []) { // ─── UNIT LIBRARY (learner view) ────────────────────────────────────────────── +// Client-side Units/Lessons browsing only ever shows INDEPENDENT content — +// anything affiliated with a course (directly, or for a lesson, through any +// of its attached units) is excluded from these listings entirely, not just +// flagged locked. This does not affect course-scoped consumption (which runs +// through ClientCoursesContext/getCourse, a separate path) or direct-link +// access to UnitDetails/LessonDetails, which still enforce access normally. exports.getUnits = async (req, res) => { try { const rows = await sequelize.query(` SELECT - u.unit_id, u.uuid, u.title, u.description, u.duration_seconds, + u.unit_id, u.uuid, u.title, u.subscription, u.description, u.duration_seconds, (SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL WHERE ul.unit_id = u.unit_id) AS lesson_count, @@ -68,6 +82,11 @@ exports.getUnits = async (req, res) => { WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id FROM units u WHERE u."deletedAt" IS NULL + AND NOT EXISTS ( + SELECT 1 FROM course_units cu + JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL + WHERE cu.unit_id = u.unit_id + ) ORDER BY u.title ASC `, { type: sequelize.QueryTypes.SELECT }); @@ -89,11 +108,12 @@ exports.getUnits = async (req, res) => { coursesByUnit.set(row.unit_id, list); } - // is_locked mirrors canAccessUnit: standalone units are open, attached units - // need at least one accessible course. + // is_locked mirrors canAccessUnit: a unit with its own subscription or at + // least one attached course needs an access check; a fully open standalone + // unit (no subscription, no course links) is never locked. const result = []; for (const row of rows) { - const is_locked = 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)) : false; result.push({ ...row, courses: coursesByUnit.get(row.unit_id) ?? [], is_locked }); @@ -106,6 +126,66 @@ exports.getUnits = async (req, res) => { } }; +// ─── LESSON LIBRARY (learner view) ──────────────────────────────────────────── +// Mirrors getUnits above — a Lesson may sit in several Units (each possibly in +// different courses), so is_locked/courses are resolved across ALL attached +// units rather than a single direct course link. + +exports.getLessons = async (req, res) => { + try { + const rows = await sequelize.query(` + SELECT + l.lesson_id, l.uuid, l.title, l.description, l.duration_seconds, + (SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul + JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL + WHERE ul.lesson_id = l.lesson_id) AS unit_count + FROM lessons l + WHERE l."deletedAt" IS NULL + AND NOT EXISTS ( + SELECT 1 FROM unit_lessons ul + JOIN course_units cu ON cu.unit_id = ul.unit_id + JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL + WHERE ul.lesson_id = l.lesson_id + ) + ORDER BY l.title ASC + `, { type: sequelize.QueryTypes.SELECT }); + + // Batch-fetch every course reachable through any attached unit, for every + // returned lesson, in one query — same batching style as getUnits. + const lessonIds = rows.map((r) => r.lesson_id); + const courseLinkRows = lessonIds.length ? await sequelize.query(` + SELECT DISTINCT ul.lesson_id, c.course_id, c.uuid, c.title, c.subscription + FROM unit_lessons ul + JOIN course_units cu ON cu.unit_id = ul.unit_id + JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL + WHERE ul.lesson_id IN (:lessonIds) + `, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : []; + + const coursesByLesson = new Map(); + for (const row of courseLinkRows) { + const list = coursesByLesson.get(row.lesson_id) ?? []; + list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription }); + coursesByLesson.set(row.lesson_id, list); + } + + // is_locked mirrors canAccessLesson: standalone/unattached lessons are + // open, attached lessons need at least one accessible course through + // any attached unit. + const result = []; + for (const row of rows) { + const is_locked = Number(row.unit_count) > 0 + ? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id)) + : false; + result.push({ ...row, courses: coursesByLesson.get(row.lesson_id) ?? [], is_locked }); + } + + return R.success(res, "Lessons retrieved.", result); + } catch (err) { + console.error("[CLIENT][LESSONS][GET ALL]", err); + return R.error(res, "Could not retrieve lessons.", 500); + } +}; + // ─── STANDALONE UNIT QUIZ ───────────────────────────────────────────────────── exports.getUnitQuiz = async (req, res) => { diff --git a/cron/client.cron.js b/cron/client.cron.js index b696264..797eef7 100644 --- a/cron/client.cron.js +++ b/cron/client.cron.js @@ -21,6 +21,7 @@ const userNotifications = require('./jobs/user_notifications.cron'); const issueCertificates = require('./jobs/issue_certificates.cron'); const expireUserTiers = require('./jobs/expire_user_tiers.cron'); +const taskDueSoon = require('./jobs/task_due_soon.cron'); const { startSettingsBackedJobs } = require('./cronRegistry.util'); // ─── Registry — add future client-side cron jobs here ──────────────────────── @@ -28,6 +29,7 @@ const jobs = [ userNotifications, issueCertificates, expireUserTiers, + taskDueSoon, ]; // ─── Boot all registered client-side jobs ───────────────────────────────────── diff --git a/cron/jobs/task_due_soon.cron.js b/cron/jobs/task_due_soon.cron.js new file mode 100644 index 0000000..d424e55 --- /dev/null +++ b/cron/jobs/task_due_soon.cron.js @@ -0,0 +1,114 @@ +/*********************************************************************************************************************************************************************** + * File Name : task_due_soon.cron.js + * Type : Cron Job + * Description : Emits a learner-facing "task_reminder" UserNotification for + * each user who has NOT yet completed a task whose deadline + * falls ~24h from now. Unlike task_overdue.cron.js (a single + * admin-facing status flip), completion here is per-user, so + * each candidate task's assigned-group members are checked + * individually via checkTaskCompletion before notifying. + * + * "Falls ~24h from now" = deadline between (now + 23h) and + * (now + 24h), a 1-hour sliding window — since this runs + * hourly, each task's deadline crosses that window exactly + * once, giving a single reminder ~24h before it's due + * without needing a separate "already notified" table. + * + * Schedule : Every hour, 10 minutes past ("10 * * * *"). Registered by + * cron/client.cron.js. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 9, 2026 + ***********************************************************************************************************************************************************************/ +const { Op, QueryTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); +const { Task } = require('../../models/task/task.mdl'); +const UserNotification = require('../../models/notifications/user_notification.mdl'); +const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); +const { renderNotification } = require('../../services/notificationTemplate.service'); +const { checkTaskCompletion } = require('../../controllers/client/task.controller'); + +const WINDOW_START_MS = 23 * 60 * 60 * 1000; +const WINDOW_END_MS = 24 * 60 * 60 * 1000; + +async function run() { + const settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskDueSoon' } }); + if (settings && !settings.enabled) return; + + const now = Date.now(); + + let dueSoonTasks; + try { + dueSoonTasks = await Task.findAll({ + attributes: ['task_id', 'task_list_id', 'name', 'deadline'], + where: { + deadline: { + [Op.gte]: new Date(now + WINDOW_START_MS), + [Op.lt]: new Date(now + WINDOW_END_MS), + }, + status: { [Op.notIn]: ['completed', 'overdue'] }, + }, + raw: true, + }); + } catch (err) { + console.error('[CRON][TASK DUE SOON] Failed to query upcoming deadlines:', err); + return; + } + + if (!dueSoonTasks.length) return; + + console.log(`[CRON][TASK DUE SOON] ${dueSoonTasks.length} task(s) due in ~24h — resolving affected users.`); + + try { + const taskListIds = [...new Set(dueSoonTasks.map((t) => t.task_list_id))]; + const memberRows = await sequelize.query( + `SELECT DISTINCT tlg.task_list_id, ugm.user_id + FROM task_list_groups tlg + JOIN user_group_members ugm ON ugm.group_id = tlg.group_id + AND ugm."deletedAt" IS NULL + WHERE tlg.task_list_id IN (:taskListIds)`, + { replacements: { taskListIds }, type: QueryTypes.SELECT } + ); + + const usersByTaskList = new Map(); + for (const row of memberRows) { + const list = usersByTaskList.get(row.task_list_id) ?? []; + list.push(row.user_id); + usersByTaskList.set(row.task_list_id, list); + } + + const now2 = new Date(); + let notifiedCount = 0; + + for (const task of dueSoonTasks) { + const candidateUserIds = usersByTaskList.get(task.task_list_id) ?? []; + if (!candidateUserIds.length) continue; + + const incompleteUserIds = []; + for (const userId of candidateUserIds) { + const done = await checkTaskCompletion(userId, task.task_id); + if (!done) incompleteUserIds.push(userId); + } + if (!incompleteUserIds.length) continue; + + const notify = await renderNotification({ type: 'task_reminder', data: { + taskName: task.name, deadline: task.deadline, + } }); + await UserNotification.bulkCreate( + incompleteUserIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now2, updatedAt: now2 })), + { validate: false } + ); + notifiedCount += incompleteUserIds.length; + } + + console.log(`[CRON][TASK DUE SOON] Sent ${notifiedCount} reminder(s) across ${dueSoonTasks.length} task(s).`); + } catch (err) { + console.error('[CRON][TASK DUE SOON] Failed to emit reminders:', err); + } +} + +module.exports = { + name: 'taskDueSoon', + schedule: '10 * * * *', + run, +}; diff --git a/data/notification_template_enrichers.data.js b/data/notification_template_enrichers.data.js index 425f0fe..ff726c2 100644 --- a/data/notification_template_enrichers.data.js +++ b/data/notification_template_enrichers.data.js @@ -29,6 +29,12 @@ const ENRICHERS = { deadline: fmtDate(data.deadline), }), + task_submission_reviewed: (data) => ({ + ...data, + statusLabel: data.status === 'approved' ? 'approved' : 'rejected', + reviewNoteSuffix: data.review_note ? ` Note: ${data.review_note}` : '', + }), + welcome: (data) => { const greeting = data.accType === 'admin' ? 'Welcome, Administrator!' diff --git a/database/migrations/20260708000001-add-sticky-and-notification-flags.js b/database/migrations/20260708000001-add-sticky-and-notification-flags.js new file mode 100644 index 0000000..5367f2b --- /dev/null +++ b/database/migrations/20260708000001-add-sticky-and-notification-flags.js @@ -0,0 +1,57 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + // ─── notification_broadcasts ──────────────────────────────────────────── + await queryInterface.addColumn('notification_broadcasts', 'show_in_sticky', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + + await queryInterface.addColumn('notification_broadcasts', 'show_in_notifications', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }); + + // ─── admin_notifications (admin bell) ─────────────────────────────────── + await queryInterface.addColumn('admin_notifications', 'show_in_notifications', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }); + + // Not used by admin UI right now, but keeps filtering semantics symmetric. + await queryInterface.addColumn('admin_notifications', 'show_in_sticky', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + + // ─── user_notifications (client notifications + sticky banner) ──────── + await queryInterface.addColumn('user_notifications', 'show_in_notifications', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }); + + await queryInterface.addColumn('user_notifications', 'show_in_sticky', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('user_notifications', 'show_in_sticky'); + await queryInterface.removeColumn('user_notifications', 'show_in_notifications'); + + await queryInterface.removeColumn('admin_notifications', 'show_in_sticky'); + await queryInterface.removeColumn('admin_notifications', 'show_in_notifications'); + + await queryInterface.removeColumn('notification_broadcasts', 'show_in_notifications'); + await queryInterface.removeColumn('notification_broadcasts', 'show_in_sticky'); + }, +}; + diff --git a/database/migrations/20260709000001-add-requires-review-and-prompt-to-task-requirements.js b/database/migrations/20260709000001-add-requires-review-and-prompt-to-task-requirements.js new file mode 100644 index 0000000..f8b1ac4 --- /dev/null +++ b/database/migrations/20260709000001-add-requires-review-and-prompt-to-task-requirements.js @@ -0,0 +1,30 @@ +'use strict'; + +// Part 1A — new requirement types (submit_text, pass_quiz) + review workflow. +// `prompt` is submit_text's instructions field (mirrors link_label/reference_label +// as the type-specific display field). `requires_review` opts any submission-based +// requirement into admin approve/reject before it counts as complete. +// task_requirements.type has no DB-level CHECK constraint (verified against the +// live schema — Sequelize enforces the ENUM only at the application layer for +// this table), so no constraint migration is needed to add the new type values. + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('task_requirements', 'prompt', { + type: Sequelize.TEXT, + allowNull: true, + after: 'reference_label', + }); + await queryInterface.addColumn('task_requirements', 'requires_review', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + after: 'prompt', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('task_requirements', 'requires_review'); + await queryInterface.removeColumn('task_requirements', 'prompt'); + }, +}; diff --git a/database/migrations/20260709000002-add-review-fields-to-task-completions.js b/database/migrations/20260709000002-add-review-fields-to-task-completions.js new file mode 100644 index 0000000..3c29a6a --- /dev/null +++ b/database/migrations/20260709000002-add-review-fields-to-task-completions.js @@ -0,0 +1,55 @@ +'use strict'; + +// Part 1A — completions gain a review workflow (submitted/approved/rejected) +// and a response_text field for the new submit_text requirement type. +// `status` is added as STRING + an explicit CHECK constraint, matching the +// pattern task_progress.type already uses on this CockroachDB instance +// (createTable auto-generates `check_type`; addColumn does not, so we add it +// ourselves) rather than the ENUM-with-no-constraint gap task_requirements.type +// happens to have today. + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('task_completions', 'response_text', { + type: Sequelize.TEXT, + allowNull: true, + after: 'note', + }); + await queryInterface.addColumn('task_completions', 'status', { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'submitted', + after: 'response_text', + }); + await queryInterface.sequelize.query( + `ALTER TABLE task_completions ADD CONSTRAINT check_status + CHECK (status IN ('submitted', 'approved', 'rejected'))` + ); + await queryInterface.addColumn('task_completions', 'reviewed_by', { + type: Sequelize.BIGINT, + allowNull: true, + after: 'status', + }); + await queryInterface.addColumn('task_completions', 'reviewed_at', { + type: Sequelize.DATE, + allowNull: true, + after: 'reviewed_by', + }); + await queryInterface.addColumn('task_completions', 'review_note', { + type: Sequelize.TEXT, + allowNull: true, + after: 'reviewed_at', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('task_completions', 'review_note'); + await queryInterface.removeColumn('task_completions', 'reviewed_at'); + await queryInterface.removeColumn('task_completions', 'reviewed_by'); + await queryInterface.sequelize.query( + `ALTER TABLE task_completions DROP CONSTRAINT IF EXISTS check_status` + ); + await queryInterface.removeColumn('task_completions', 'status'); + await queryInterface.removeColumn('task_completions', 'response_text'); + }, +}; diff --git a/database/migrations/20260709000003-add-pass-quiz-to-task-progress-type.js b/database/migrations/20260709000003-add-pass-quiz-to-task-progress-type.js new file mode 100644 index 0000000..0801eb8 --- /dev/null +++ b/database/migrations/20260709000003-add-pass-quiz-to-task-progress-type.js @@ -0,0 +1,28 @@ +'use strict'; + +// Part 1A — task_progress.type gains 'pass_quiz' (reference_id becomes a +// quiz_id for that type; completed is computed from QuizAttempt.passed the +// same way unit-quiz has_passed already is). Same drop/recreate CHECK +// constraint pattern as 20260101000058-add-exclusive-to-course-subscription.js. + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `ALTER TABLE task_progress DROP CONSTRAINT IF EXISTS check_type` + ); + await queryInterface.sequelize.query( + `ALTER TABLE task_progress ADD CONSTRAINT check_type + CHECK (type IN ('read_course', 'read_unit', 'read_lesson', 'pass_quiz'))` + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query( + `ALTER TABLE task_progress DROP CONSTRAINT IF EXISTS check_type` + ); + await queryInterface.sequelize.query( + `ALTER TABLE task_progress ADD CONSTRAINT check_type + CHECK (type IN ('read_course', 'read_unit', 'read_lesson'))` + ); + }, +}; diff --git a/database/migrations/20260709000004-add-task-review-assign-complete-notification-templates.js b/database/migrations/20260709000004-add-task-review-assign-complete-notification-templates.js new file mode 100644 index 0000000..ea56dc9 --- /dev/null +++ b/database/migrations/20260709000004-add-task-review-assign-complete-notification-templates.js @@ -0,0 +1,42 @@ +'use strict'; + +// Part 1A/1C — new learner-facing notification types for the task review +// workflow, group assignment, and per-task completion. Seeded already-published +// (status: 'sent') like every other is_system row in the original +// 20260703000008 migration, so the trigger call sites work immediately without +// requiring an admin to publish them first. + +module.exports = { + async up(queryInterface) { + const now = new Date(); + await queryInterface.bulkInsert('notification_templates', [ + { + type: 'task_submission_reviewed', notify_type: 'task', scope: 'user', is_system: true, + label: 'Task Submission Reviewed', status: 'sent', + title: 'Submission Reviewed', + message: 'Your submission for "{{taskName}}" was {{statusLabel}}.{{reviewNoteSuffix}}', + createdAt: now, updatedAt: now, + }, + { + type: 'task_assigned', notify_type: 'task', scope: 'user', is_system: true, + label: 'Task List Assigned', status: 'sent', + title: 'New Task Assigned', + message: 'You have been assigned "{{taskListName}}" — {{taskCount}} task(s) to complete.', + createdAt: now, updatedAt: now, + }, + { + type: 'task_completed', notify_type: 'task', scope: 'user', is_system: true, + label: 'Task Completed', status: 'sent', + title: 'Task Completed', + message: 'You completed "{{taskName}}".', + createdAt: now, updatedAt: now, + }, + ]); + }, + + async down(queryInterface) { + await queryInterface.bulkDelete('notification_templates', { + type: ['task_submission_reviewed', 'task_assigned', 'task_completed'], + }); + }, +}; diff --git a/database/migrations/20260709000005-add-order-index-and-is-required-to-tasks.js b/database/migrations/20260709000005-add-order-index-and-is-required-to-tasks.js new file mode 100644 index 0000000..90b2e11 --- /dev/null +++ b/database/migrations/20260709000005-add-order-index-and-is-required-to-tasks.js @@ -0,0 +1,39 @@ +'use strict'; + +// Part 1B — task_lists' tasks currently sort only by createdAt (no ordering +// column exists). Add order_index (position within the list) and is_required +// (mirrors unit_quizzes.is_required — an optional task doesn't block anything +// after it in the sequencing lock). + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('tasks', 'order_index', { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + after: 'deadline', + }); + await queryInterface.addColumn('tasks', 'is_required', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + after: 'order_index', + }); + + // Backfill existing rows with a stable order matching current createdAt + // sort, so tasks don't all collapse to order_index 0 on first use. + await queryInterface.sequelize.query(` + UPDATE tasks t SET order_index = sub.rn - 1 + FROM ( + SELECT task_id, ROW_NUMBER() OVER (PARTITION BY task_list_id ORDER BY "createdAt" ASC) AS rn + FROM tasks + ) sub + WHERE t.task_id = sub.task_id + `); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('tasks', 'is_required'); + await queryInterface.removeColumn('tasks', 'order_index'); + }, +}; diff --git a/database/migrations/20260709000006-add-features-to-tier-plans.js b/database/migrations/20260709000006-add-features-to-tier-plans.js new file mode 100644 index 0000000..6a5dc64 --- /dev/null +++ b/database/migrations/20260709000006-add-features-to-tier-plans.js @@ -0,0 +1,19 @@ +'use strict'; + +// Part 2A — admin-authored bullet list ("what's included") per plan, replacing +// the client's hardcoded 4-item static perks list. Same JSONB-array-of-objects +// pattern already used for plan_policies.access_rules / payment_policies.promo_rules. + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('tier_plans', 'features', { + type: Sequelize.JSONB, + allowNull: true, + after: 'description', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('tier_plans', 'features'); + }, +}; diff --git a/database/migrations/20260709000007-add-subscription-to-units.js b/database/migrations/20260709000007-add-subscription-to-units.js new file mode 100644 index 0000000..ee792a7 --- /dev/null +++ b/database/migrations/20260709000007-add-subscription-to-units.js @@ -0,0 +1,23 @@ +'use strict'; + +// Part 2B — standalone (non-course) units currently can never be tier-gated: +// canAccessUnit only ever checks course links, and an independent unit by +// definition has none. Add a direct, optional subscription field so a +// standalone unit can require a tier on its own. Deliberately no CHECK +// constraint — mirrors 20260101000065-drop-tier-enum-check-user-tiers.js's +// fix, since tier slugs are admin-defined (tier_categories.slug) and not a +// fixed enum. + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('units', 'subscription', { + type: Sequelize.STRING(50), + allowNull: true, + after: 'title', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('units', 'subscription'); + }, +}; diff --git a/models/courses/lessons.mdl.js b/models/courses/lessons.mdl.js index c87e33e..dde38d4 100644 --- a/models/courses/lessons.mdl.js +++ b/models/courses/lessons.mdl.js @@ -8,7 +8,7 @@ const Lesson = sequelize.define("Lesson", { uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0 }, title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1 }, - description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: false, order: 2 }, + description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 2 }, duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, // computed from blocks on save diff --git a/models/courses/units.mdl.js b/models/courses/units.mdl.js index b7312fa..5fe58f5 100644 --- a/models/courses/units.mdl.js +++ b/models/courses/units.mdl.js @@ -7,6 +7,7 @@ const Unit = sequelize.define("Unit", { unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: false }, uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0, filterable: false }, title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1, filterable: true }, + subscription: { type: DataTypes.STRING(50), allowNull: true, label: "Subscription", hidden: false, order: 1.5, filterable: true, comment: "Optional direct tier gate for standalone units — null means open (or gated only via an attached course)." }, description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 0, filterable: false }, duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 2, filterable: false }, createdBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true }, diff --git a/models/notifications/admin_notification.mdl.js b/models/notifications/admin_notification.mdl.js index 6c789e1..1d86065 100644 --- a/models/notifications/admin_notification.mdl.js +++ b/models/notifications/admin_notification.mdl.js @@ -29,6 +29,19 @@ const AdminNotification = sequelize.define('AdminNotification', { type: DataTypes.TEXT, allowNull: false, }, + + // Controls where this notification is rendered in the admin UI. + show_in_notifications: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + show_in_sticky: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + data: { type: DataTypes.JSONB, allowNull: true, diff --git a/models/notifications/notification_broadcast.mdl.js b/models/notifications/notification_broadcast.mdl.js index 6f020c5..a9d4b52 100644 --- a/models/notifications/notification_broadcast.mdl.js +++ b/models/notifications/notification_broadcast.mdl.js @@ -13,6 +13,13 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", { title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 }, message: { type: DataTypes.TEXT, allowNull: false, label: "Message", order: 2 }, + // ─── Visibility ────────────────────────────────────────────────────────── + // Determines where a delivered announcement shows up for recipients. + // - show_in_sticky: client sticky banner (fixed top) + // - show_in_notifications: client + admin notifications lists/bells + show_in_sticky: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: "Show in Sticky Announcements", order: 2.5 }, + show_in_notifications: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Show in Notifications", order: 2.6 }, + // ─── Targeting ──────────────────────────────────────────────────────────── target_type: { type: DataTypes.ENUM("admin", "user", "both", "task_list", "course", "tier_plan"), diff --git a/models/notifications/user_notification.mdl.js b/models/notifications/user_notification.mdl.js index a3bf83c..052d84c 100644 --- a/models/notifications/user_notification.mdl.js +++ b/models/notifications/user_notification.mdl.js @@ -33,6 +33,21 @@ const UserNotification = sequelize.define('UserNotification', { type: DataTypes.TEXT, allowNull: false, }, + + // Controls where this notification is rendered. + // - show_in_notifications: appears in the /notifications list + bell badge. + // - show_in_sticky: appears in the fixed "sticky announcement" banner. + show_in_notifications: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + show_in_sticky: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + data: { type: DataTypes.JSONB, allowNull: true, diff --git a/models/task/task.mdl.js b/models/task/task.mdl.js index 8638b82..e59108e 100644 --- a/models/task/task.mdl.js +++ b/models/task/task.mdl.js @@ -41,6 +41,8 @@ const Task = sequelize.define('Task', { name: { type: DataTypes.STRING, allowNull: false, validate: { notEmpty: true }, order: 1, filterable: true }, description: { type: DataTypes.TEXT, allowNull: true, hidden: true, filterable: false }, deadline: { type: DataTypes.DATE, allowNull: true, order: 2, filterable: true }, + order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, order: 2.1, filterable: true, comment: 'Position within the task list — drives sequencing lock.' }, + is_required: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, order: 2.2, filterable: true, comment: 'Optional tasks do not block later tasks in the sequencing lock.' }, status: { type: DataTypes.ENUM('pending', 'in_progress', 'completed', 'overdue'), defaultValue: 'pending', allowNull: false, filterable: true }, // ── Audit trails ──────────────────────────────────────────────────────── @@ -56,7 +58,7 @@ const Task = sequelize.define('Task', { const TaskRequirement = sequelize.define('TaskRequirement', { requirement_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, task_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' } }, - type: { type: DataTypes.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson'), allowNull: false, filterable: true }, + type: { type: DataTypes.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson', 'submit_text', 'pass_quiz'), allowNull: false, filterable: true }, // ── visit_link ────────────────────────────────────────────────────────── link_url: { type: DataTypes.STRING, allowNull: true, filterable: false }, @@ -66,9 +68,16 @@ const TaskRequirement = sequelize.define('TaskRequirement', { allowed_file_types: { type: DataTypes.JSONB, allowNull: true, filterable: false }, max_file_count: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1, filterable: false }, - // ── read_course / read_unit / read_lesson ──────────────────────────────── - reference_id: { type: DataTypes.UUID, allowNull: true, comment: 'course_id | unit_id | lesson_id depending on type', filterable: false }, + // ── read_course / read_unit / read_lesson / pass_quiz ───────────────────── + reference_id: { type: DataTypes.UUID, allowNull: true, comment: 'course_id | unit_id | lesson_id | quiz_id depending on type', filterable: false }, reference_label: { type: DataTypes.STRING, allowNull: true, comment: 'Cached display name so we do not always join', filterable: false }, + + // ── submit_text ───────────────────────────────────────────────────────── + prompt: { type: DataTypes.TEXT, allowNull: true, comment: 'Instructions shown above the free-text response box.', filterable: false }, + + // ── upload_file / submit_text ────────────────────────────────────────── + requires_review: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, filterable: true, comment: 'If true, a submission only counts as complete once an admin approves it.' }, + order: { type: DataTypes.INTEGER, defaultValue: 0, filterable: true }, // ── Audit trails ──────────────────────────────────────────────────────── diff --git a/models/task/task_completion.mdl.js b/models/task/task_completion.mdl.js index 60ba428..32a21c8 100644 --- a/models/task/task_completion.mdl.js +++ b/models/task/task_completion.mdl.js @@ -42,13 +42,31 @@ const TaskCompletion = sequelize.define('TaskCompletion', { comment: 'Optional note the user attaches when submitting.', order: 1, }, + response_text: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'The learner\'s free-text answer, for submit_text requirements.', + order: 1.5, + }, submitted_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, allowNull: false, order: 2, }, - + + // ── Review workflow (requires_review requirements only) ────────────────── + status: { + type: DataTypes.ENUM('submitted', 'approved', 'rejected'), + allowNull: false, + defaultValue: 'submitted', + order: 2.1, + filterable: true, + }, + reviewed_by: { type: DataTypes.BIGINT, allowNull: true, order: 2.2 }, + reviewed_at: { type: DataTypes.DATE, allowNull: true, order: 2.3 }, + review_note: { type: DataTypes.TEXT, allowNull: true, comment: 'Optional note the admin leaves when approving/rejecting.', order: 2.4 }, + // ── Audit trails ──────────────────────────────────────────────────────── createdBy: { type: DataTypes.BIGINT, allowNull: true, order: 6 }, updatedBy: { type: DataTypes.BIGINT, allowNull: true, order: 7 }, diff --git a/models/task/task_progress.mdl.js b/models/task/task_progress.mdl.js index f127a46..cd1c27f 100644 --- a/models/task/task_progress.mdl.js +++ b/models/task/task_progress.mdl.js @@ -99,10 +99,10 @@ const TaskProgress = sequelize.define('TaskProgress', { reference_id: { type: DataTypes.UUID, allowNull: false, - comment: 'course_id | unit_id | lesson_id depending on type.', + comment: 'course_id | unit_id | lesson_id | quiz_id depending on type.', }, type: { - type: DataTypes.ENUM('read_course', 'read_unit', 'read_lesson'), + type: DataTypes.ENUM('read_course', 'read_unit', 'read_lesson', 'pass_quiz'), allowNull: false, }, completed: { diff --git a/models/tiers/tier_plans.mdl.js b/models/tiers/tier_plans.mdl.js index 38eff2e..d1b56d6 100644 --- a/models/tiers/tier_plans.mdl.js +++ b/models/tiers/tier_plans.mdl.js @@ -15,6 +15,7 @@ const mdl_TierPlans = sequelize.define('TierPlan', { tier: { type: DataTypes.STRING(50), allowNull: false, label: 'Tier' }, label: { type: DataTypes.STRING(100), allowNull: false, label: 'Plan Label' }, description: { type: DataTypes.TEXT, allowNull: true, label: 'Description' }, + features: { type: DataTypes.JSONB, allowNull: true, label: 'Features', comment: 'Array of {text} — admin-authored "what\'s included" bullets shown on the client Plans page.' }, duration_days: { type: DataTypes.FLOAT, allowNull: false, label: 'Duration (Days)' }, duration_unit: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'day', label: 'Duration Unit' }, price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' }, diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index 8c46022..cfa13e5 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -69,6 +69,10 @@ router.use('/notifications', notificationRoutes); router.use('/notification-broadcasts', notificationBroadcastRoutes); router.use('/notification-settings', notificationSettingsRoutes); router.use('/notification-templates', notificationTemplatesRoutes); +// Announcements (alias routes for notification broadcasts/templates/settings) +router.use('/announcements', notificationBroadcastRoutes); +router.use('/announcement-settings', notificationSettingsRoutes); +router.use('/announcement-templates', notificationTemplatesRoutes); router.use('/media', mediaRoutes); router.use('/achievements', achievementsRoutes); diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index fce8c6f..f06bfe6 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -14,6 +14,7 @@ router.use(authenticate, requireAdmin(), adminLimiter); router.get("/", ctrl.getCourses); router.post("/", ctrl.createCourse); +router.post("/full", ctrl.createCourseFull); // one-shot Add Course wizard submit (course + roadmap + rewards) // ── static segments first ──────────────────────────────────────────────────── router.get("/field-values", ctrl.getCourseFieldValues); @@ -25,6 +26,7 @@ router.get("/flat", ctrl.getCoursesFlat); router.get("/by-subscription", ctrl.getCoursesBySubscription); router.get("/units-flat", ctrl.getUnitsFlat); router.get("/lessons-flat", ctrl.getLessonsFlat); +router.get("/quizzes-flat", ctrl.getQuizzesFlat); // ── then :courseId ──────────────────────────────────────────────────────────── router.get("/archives/:courseId", ctrl.getArchivedCourse); diff --git a/routes/admin/task.routes.js b/routes/admin/task.routes.js index 24303dc..d9f3587 100644 --- a/routes/admin/task.routes.js +++ b/routes/admin/task.routes.js @@ -33,6 +33,7 @@ router.post("/:taskListId/tasks", sensitiveOpsLimiter, controller.createTask); router.post("/:taskListId/tasks/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTasks); router.post("/:taskListId/tasks/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTasks); router.post("/:taskListId/tasks/bulk-delete", sensitiveOpsLimiter, controller.bulkPermanentlyDeleteTasks); +router.patch("/:taskListId/tasks/order", sensitiveOpsLimiter, controller.reorderTasks); // ─── Tasks (single) ─────────────────────────────────────────────────────────── router.get("/:taskListId/tasks/:taskId", controller.getTask); @@ -47,6 +48,7 @@ router.get("/:taskListId/tasks/:taskId/completions/user/:userId", completionCont router.post("/:taskListId/tasks/:taskId/completions/bulk-archive", sensitiveOpsLimiter, completionController.bulkArchiveCompletions); router.post("/:taskListId/tasks/:taskId/completions/bulk-restore", sensitiveOpsLimiter, completionController.bulkRestoreCompletions); router.get("/:taskListId/tasks/:taskId/completions/:completionId", completionController.getCompletion); +router.patch("/:taskListId/tasks/:taskId/completions/:completionId/review", sensitiveOpsLimiter, completionController.reviewSubmission); router.delete("/:taskListId/tasks/:taskId/completions/:completionId", sensitiveOpsLimiter, completionController.archiveCompletion); router.patch("/:taskListId/tasks/:taskId/completions/:completionId/restore", sensitiveOpsLimiter, completionController.restoreCompletion); diff --git a/routes/client/courses.routes.js b/routes/client/courses.routes.js index 45a32b4..41273f1 100644 --- a/routes/client/courses.routes.js +++ b/routes/client/courses.routes.js @@ -14,6 +14,7 @@ router.get('/uuid/:uuid', ctrl.getCourseByUuid); router.get('/unit/uuid/:uuid/lessons', ctrl.getLessonsByUnitUuid); router.get('/unit/uuid/:uuid', ctrl.getUnitByUuid); router.get('/lesson/uuid/:uuid', ctrl.getLessonByUuid); +router.get('/quiz/uuid/:uuid', ctrl.getQuizByUuid); // Courses router.get('/', ctrl.getCourses); diff --git a/routes/client/lessons.routes.js b/routes/client/lessons.routes.js index 7623c52..cb736cc 100644 --- a/routes/client/lessons.routes.js +++ b/routes/client/lessons.routes.js @@ -3,6 +3,7 @@ * Type of Program: Router * Description: Standalone Lesson consumption — each Lesson runs independently. * + * GET /client/lessons → learner-facing lesson library * GET /client/lessons/:uuid → lesson with full block content * POST /client/lessons/:uuid/progress → standalone reading progress * (course NULL; body.unit_uuid optional) @@ -14,6 +15,7 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../../controllers/client/units.controller'); +router.get('/', ctrl.getLessons); router.get('/:uuid', ctrl.getLessonByUuid); router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress); diff --git a/routes/client/notifications.routes.js b/routes/client/notifications.routes.js index da50931..17c1c51 100644 --- a/routes/client/notifications.routes.js +++ b/routes/client/notifications.routes.js @@ -16,10 +16,11 @@ ***********************************************************************************************************************************************************************/ const express = require('express'); const router = express.Router(); -const { list, unseenCount, markSeen, markAllSeen, clearAll } = require('../../controllers/client/notification.controller'); +const { list, unseenCount, stickyAnnouncement, markSeen, markAllSeen, clearAll } = require('../../controllers/client/notification.controller'); router.get('/', list); router.get('/unseen', unseenCount); +router.get('/sticky', stickyAnnouncement); router.patch('/seen-all', markAllSeen); router.patch('/:id/seen', markSeen); router.delete('/clear-all', clearAll); diff --git a/services/achievements.service.js b/services/achievements.service.js index 17cf22f..23baf03 100644 --- a/services/achievements.service.js +++ b/services/achievements.service.js @@ -105,6 +105,16 @@ async function onPerfectQuiz(user_id, quiz_id) { await grantAchievement(user_id, 'perfect_quiz_score', { quiz_id }); } +/** Call after a single task's requirements are all satisfied for a user */ +async function onTaskCompleted(user_id, task_id, task_name = null) { + await grantAchievement(user_id, 'first_task_completed', { task_id, task_name }); +} + +/** Call after every task in a task list is complete for a user */ +async function onTaskListCompleted(user_id, task_list_id, task_list_name = null) { + await grantAchievement(user_id, 'task_list_finisher', { task_list_id, task_list_name }); +} + /** Call after profile is fully filled out */ async function onProfileCompleted(user_id) { await grantAchievement(user_id, 'profile_completed'); @@ -151,6 +161,8 @@ module.exports = { onPerfectQuiz, onProfileCompleted, onReferral, + onTaskCompleted, + onTaskListCompleted, // Admin adminGrantAchievement, diff --git a/services/s3.service.js b/services/s3.service.js index f2d395d..1ff97ba 100644 --- a/services/s3.service.js +++ b/services/s3.service.js @@ -159,18 +159,18 @@ async function deleteFile(key) { // For server-side reads only (e.g. media.controller.js streamAsset piping // bytes to the browser itself) — never hand this URL to a browser directly. // -// Must NOT be signed against S3_PUBLIC_URL: that host is fronted by -// garage-anon-proxy, which re-signs every request itself (header-based SigV4, -// real credentials) regardless of any query-string signature already present. -// A presigned URL arriving there collides with the proxy's own signature and -// Garage rejects the request (400 "Header `x-amz-date` should be signed"). -// See getPublicUrl() below for the browser-facing equivalent. -// TEMPORARY: -// The problem is this line do not tell for each of development machines -// which ones should be called whether it's for PUBLIC_URL or LOCAL_ENDPOINT itself. +// Must NOT be signed against S3_PUBLIC_URL when Garage is in use: that host is +// fronted by garage-anon-proxy, which re-signs every request itself (header-based +// SigV4, real credentials) regardless of any query-string signature already +// present. A presigned URL arriving there collides with the proxy's own +// signature and Garage rejects the request (400 "Header `x-amz-date` should +// be signed"). +// +// Uses the internal `s3` client (always S3_ENDPOINT) when available; falls back +// to getPublicClient() for external S3-compatible services without Garage. // async function getSignedDownloadUrl(key, expiresInSeconds = 3600) { - const client = getPublicClient(); + const client = process.env.S3_ENDPOINT ? s3 : getPublicClient(); return getSignedUrl( client, @@ -183,6 +183,7 @@ async function getSignedDownloadUrl(key, expiresInSeconds = 3600) { } ); } + // ─── getPublicUrl ───────────────────────────────────────────────────────────── // // Browser-facing pre-signed GET URL, signed against S3_PUBLIC_URL. Safe again diff --git a/utils/buildQuery.util.js b/utils/buildQuery.util.js index ac42e74..0bf9095 100644 --- a/utils/buildQuery.util.js +++ b/utils/buildQuery.util.js @@ -47,13 +47,20 @@ function buildWhere(filters = [], allowedFields = new Set()) { * @param {Array<{ id: string, desc: boolean }>} sort * @returns {Array} Sequelize order clause */ -function buildOrder(sort = [], allowedFields = new Set()) { +function buildOrder(sort = [], allowedFields = new Set(), computedFields = new Set()) { const order = []; for (const { id, desc } of sort) { if (!id) continue; if (allowedFields.size && !allowedFields.has(id)) continue; + // ─── Computed (subquery/literal) columns — order by the unqualified + // SELECT alias, since they aren't real columns on the model's table ── + if (computedFields.has(id)) { + order.push([Sequelize.literal(`"${id}"`), desc ? "DESC" : "ASC"]); + continue; + } + // ─── ENUM fields — use CASE for custom sort order ───────────────────── if (ENUM_SORT_ORDER[id]) { const sequence = desc @@ -88,12 +95,16 @@ function buildOrder(sort = [], allowedFields = new Set()) { * @param {Array} sort * @returns {{ where: Object, order: Array }} */ -function buildQuery(filters = [], sort = [], allowedFields = []) { +function buildQuery(filters = [], sort = [], allowedFields = [], computedFields = []) { const fieldSet = new Set(allowedFields); + const computedSet = new Set(computedFields); + const orderFieldSet = new Set([...allowedFields, ...computedFields]); return { + // Computed (subquery/literal) columns aren't real table columns — filtering + // via Sequelize.col() would error, so only allow them in ORDER BY, not WHERE. where: buildWhere(filters, fieldSet), - order: buildOrder(sort, fieldSet), + order: buildOrder(sort, orderFieldSet, computedSet), }; } diff --git a/utils/paginate.util.js b/utils/paginate.util.js index 3352a9f..17197ae 100644 --- a/utils/paginate.util.js +++ b/utils/paginate.util.js @@ -97,8 +97,9 @@ async function paginate(model, req, { const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas, context }); const ALLOWED_FIELDS = attributes.map((a) => a.field); + const computedFieldKeys = computedAttributes.map((c) => c.key); - const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS); + const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS, computedFieldKeys); // Build attribute includes: jsonb + audit subqueries + any extra from findOptions const baseIncludes = jsonbAttr ? [jsonbAttr] : [];