mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
units,lesson as standalone
This commit is contained in:
@@ -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) => {
|
exports.updateCourse = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
@@ -521,6 +684,19 @@ exports.attachUnits = async (req, res) => {
|
|||||||
const existingSet = new Set(existing.map((r) => String(r.unit_id)));
|
const existingSet = new Set(existing.map((r) => String(r.unit_id)));
|
||||||
const toAttach = unit_ids.filter((id) => !existingSet.has(String(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);
|
let order = await nextOrderIndex(CourseUnit, { course_id: courseId }, t);
|
||||||
await CourseUnit.bulkCreate(
|
await CourseUnit.bulkCreate(
|
||||||
toAttach.map((unit_id) => ({
|
toAttach.map((unit_id) => ({
|
||||||
@@ -631,6 +807,12 @@ exports.createUnit = async (req, res) => {
|
|||||||
await t.rollback();
|
await t.rollback();
|
||||||
return R.error(res, "Unit is already attached to this course.", 409);
|
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 {
|
} else {
|
||||||
if (!title) return R.error(res, "Title is required.", 400);
|
if (!title) return R.error(res, "Title is required.", 400);
|
||||||
unit = await Unit.create({
|
unit = await Unit.create({
|
||||||
@@ -2119,6 +2301,46 @@ exports.getLessonsFlat = async (req, res) => {
|
|||||||
return R.error(res, "Could not retrieve lessons.", 500);
|
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
|
// COURSE INSTRUCTORS
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ const LESSON_LIST_COMPUTED = [
|
|||||||
WHERE ul.lesson_id = "Lesson"."lesson_id"
|
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
|
WHERE ul.lesson_id = l.lesson_id) AS unit_count
|
||||||
FROM lessons l
|
FROM lessons l
|
||||||
WHERE l."deletedAt" IS NULL
|
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
|
ORDER BY l.title ASC
|
||||||
`, { type: sequelize.QueryTypes.SELECT });
|
`, { type: sequelize.QueryTypes.SELECT });
|
||||||
return R.success(res, "Lessons retrieved.", rows);
|
return R.success(res, "Lessons retrieved.", rows);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ async function list(req, res) {
|
|||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
|
where: { show_in_notifications: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, 'Notifications fetched.', {
|
return R.success(res, 'Notifications fetched.', {
|
||||||
@@ -39,7 +40,7 @@ async function list(req, res) {
|
|||||||
// ─── GET /admin/notifications/unseen ─────────────────────────────────────────
|
// ─── GET /admin/notifications/unseen ─────────────────────────────────────────
|
||||||
async function unseenCount(req, res) {
|
async function unseenCount(req, res) {
|
||||||
try {
|
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 });
|
return R.success(res, 'Unseen count fetched.', { count });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[NOTIFICATION] unseenCount error:', err);
|
console.error('[NOTIFICATION] unseenCount error:', err);
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ async function applyBroadcastFields(broadcast, body) {
|
|||||||
if (body.title !== undefined) broadcast.title = body.title;
|
if (body.title !== undefined) broadcast.title = body.title;
|
||||||
if (body.message !== undefined) broadcast.message = body.message;
|
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 (body.target_type !== undefined) {
|
||||||
if (!ALLOWED_TARGET_TYPES.includes(body.target_type)) {
|
if (!ALLOWED_TARGET_TYPES.includes(body.target_type)) {
|
||||||
const err = new Error(`Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`);
|
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);
|
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) {
|
} catch (err) {
|
||||||
console.error("[NOTIFICATION BROADCAST][GET ALL]", 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();
|
const json = broadcast.toJSON();
|
||||||
|
|
||||||
@@ -142,7 +145,7 @@ exports.getBroadcast = async (req, res) => {
|
|||||||
|
|
||||||
await attachTargetLabels(json);
|
await attachTargetLabels(json);
|
||||||
|
|
||||||
return R.success(res, "Notification broadcast retrieved.", { data: json });
|
return R.success(res, "Announcement retrieved.", { data: json });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[NOTIFICATION BROADCAST][GET ONE]", err);
|
console.error("[NOTIFICATION BROADCAST][GET ONE]", err);
|
||||||
return R.error(res, "Internal server error.", 500);
|
return R.error(res, "Internal server error.", 500);
|
||||||
@@ -153,7 +156,15 @@ exports.getBroadcast = async (req, res) => {
|
|||||||
|
|
||||||
exports.createBroadcast = async (req, res) => {
|
exports.createBroadcast = async (req, res) => {
|
||||||
try {
|
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 (!title) return R.error(res, "title is required.", 400);
|
||||||
if (!message) return R.error(res, "message 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 (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);
|
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);
|
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
|
||||||
|
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const broadcast = await NotificationBroadcast.build({
|
const broadcast = await NotificationBroadcast.build({
|
||||||
title, message, createdBy, status: 'draft',
|
title,
|
||||||
|
message,
|
||||||
|
createdBy,
|
||||||
|
status: 'draft',
|
||||||
target_type,
|
target_type,
|
||||||
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
|
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 broadcast.save({ transaction: t });
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
|
||||||
logActivity(req.user?.user_id, 'create_notification_broadcast', { entityType: 'notification_broadcast', entityId: broadcast.broadcast_id, details: { target_type } });
|
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) {
|
} catch (dbErr) {
|
||||||
try { await t.rollback(); } catch { /* connection gone */ }
|
try { await t.rollback(); } catch { /* connection gone */ }
|
||||||
throw dbErr;
|
throw dbErr;
|
||||||
@@ -202,12 +224,17 @@ exports.updateBroadcast = async (req, res) => {
|
|||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await applyBroadcastFields(broadcast, req.body);
|
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;
|
broadcast.updatedBy = req.body.updatedBy ?? null;
|
||||||
await broadcast.save({ transaction: t });
|
await broadcast.save({ transaction: t });
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
|
||||||
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
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) {
|
} catch (dbErr) {
|
||||||
try { await t.rollback(); } catch { /* gone */ }
|
try { await t.rollback(); } catch { /* gone */ }
|
||||||
throw dbErr;
|
throw dbErr;
|
||||||
@@ -227,7 +254,7 @@ exports.sendBroadcast = async (req, res) => {
|
|||||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||||
|
|
||||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
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);
|
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400);
|
||||||
|
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
@@ -237,6 +264,8 @@ exports.sendBroadcast = async (req, res) => {
|
|||||||
|
|
||||||
const targetType = broadcast.target_type;
|
const targetType = broadcast.target_type;
|
||||||
const targetId = broadcast.target_id;
|
const targetId = broadcast.target_id;
|
||||||
|
const showInSticky = !!broadcast.show_in_sticky;
|
||||||
|
const showInNotifications = !!broadcast.show_in_notifications;
|
||||||
|
|
||||||
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
|
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
|
||||||
title: broadcast.title,
|
title: broadcast.title,
|
||||||
@@ -246,7 +275,10 @@ exports.sendBroadcast = async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (targetType === 'admin' || targetType === 'both') {
|
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;
|
recipientCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +313,8 @@ exports.sendBroadcast = async (req, res) => {
|
|||||||
seen: false,
|
seen: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
show_in_sticky: showInSticky,
|
||||||
|
show_in_notifications: showInNotifications,
|
||||||
})),
|
})),
|
||||||
{ validate: false, transaction: t }
|
{ validate: false, transaction: t }
|
||||||
);
|
);
|
||||||
@@ -294,7 +328,7 @@ exports.sendBroadcast = async (req, res) => {
|
|||||||
await t.commit();
|
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 } });
|
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) {
|
} catch (dbErr) {
|
||||||
try { await t.rollback(); } catch { /* gone */ }
|
try { await t.rollback(); } catch { /* gone */ }
|
||||||
throw dbErr;
|
throw dbErr;
|
||||||
@@ -314,12 +348,12 @@ exports.archiveBroadcast = async (req, res) => {
|
|||||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||||
|
|
||||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
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.update({ deletedBy: req.body.deletedBy ?? null });
|
||||||
await broadcast.destroy();
|
await broadcast.destroy();
|
||||||
logActivity(req.user?.user_id, 'archive_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
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) {
|
} catch (err) {
|
||||||
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
|
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
|
||||||
return R.error(res, "Internal server error.", 500);
|
return R.error(res, "Internal server error.", 500);
|
||||||
@@ -359,13 +393,13 @@ exports.restoreBroadcast = async (req, res) => {
|
|||||||
const { broadcastId } = req.params;
|
const { broadcastId } = req.params;
|
||||||
|
|
||||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
||||||
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
if (!broadcast) return R.error(res, "Announcement not found.", 404);
|
||||||
if (!broadcast.deletedAt) return R.error(res, "Notification broadcast is not archived.", 400);
|
if (!broadcast.deletedAt) return R.error(res, "Announcement is not archived.", 400);
|
||||||
|
|
||||||
await broadcast.restore();
|
await broadcast.restore();
|
||||||
await broadcast.update({ deletedBy: null });
|
await broadcast.update({ deletedBy: null });
|
||||||
logActivity(req.user?.user_id, 'restore_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
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) {
|
} catch (err) {
|
||||||
console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
|
console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
|
||||||
return R.error(res, "Internal server error.", 500);
|
return R.error(res, "Internal server error.", 500);
|
||||||
@@ -413,10 +447,10 @@ exports.getArchivedBroadcasts = async (req, res) => {
|
|||||||
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
|
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
|
||||||
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
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) {
|
} catch (err) {
|
||||||
console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", 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 { broadcastId } = req.params;
|
||||||
|
|
||||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
||||||
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
if (!broadcast) return R.error(res, "Announcement not found.", 404);
|
||||||
if (!broadcast.deletedAt) return R.error(res, "Notification broadcast must be archived before it can be permanently deleted.", 400);
|
if (!broadcast.deletedAt) return R.error(res, "Announcement must be archived before it can be permanently deleted.", 400);
|
||||||
|
|
||||||
await broadcast.destroy({ force: true });
|
await broadcast.destroy({ force: true });
|
||||||
logActivity(req.user?.user_id, 'permanently_delete_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
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) {
|
} catch (err) {
|
||||||
console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", 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);
|
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 });
|
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);
|
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);
|
const archivedIds = archived.map((b) => b.broadcast_id);
|
||||||
|
|
||||||
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: archivedIds } }, force: true });
|
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 } });
|
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,
|
deleted_ids: archivedIds,
|
||||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[NOTIFICATION BROADCAST][BULK PERMANENT DELETE]", 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ exports.getNotificationTemplates = async (req, res) => {
|
|||||||
const templates = await mdl_NotificationTemplate.findAll({
|
const templates = await mdl_NotificationTemplate.findAll({
|
||||||
order: [['notify_type', 'ASC'], ['type', 'ASC']],
|
order: [['notify_type', 'ASC'], ['type', 'ASC']],
|
||||||
});
|
});
|
||||||
return R.success(res, 'Notification templates retrieved.', templates);
|
return R.success(res, 'Announcement templates retrieved.', templates);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][GET NOTIFICATION TEMPLATES]', 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) => {
|
exports.getNotificationTemplate = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const template = await mdl_NotificationTemplate.findByPk(req.params.id);
|
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);
|
||||||
return R.success(res, 'Notification template retrieved.', template);
|
return R.success(res, 'Announcement template retrieved.', template);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][GET NOTIFICATION TEMPLATE]', 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) => {
|
exports.updateNotificationTemplate = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const template = await mdl_NotificationTemplate.findByPk(req.params.id);
|
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;
|
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 } });
|
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) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][UPDATE NOTIFICATION TEMPLATE]', 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
|||||||
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
||||||
const { TaskCompletion } = require('../../models/task/task_completion.mdl');
|
const { TaskCompletion } = require('../../models/task/task_completion.mdl');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
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 ──
|
// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ──
|
||||||
const normalizeUrl = (url) => {
|
const normalizeUrl = (url) => {
|
||||||
@@ -36,7 +37,7 @@ const normalizeUrl = (url) => {
|
|||||||
|
|
||||||
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
|
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
|
||||||
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
|
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 = {
|
const FILTERABLE_MODELS = {
|
||||||
TaskList: TaskList,
|
TaskList: TaskList,
|
||||||
Task: Task,
|
Task: Task,
|
||||||
@@ -420,6 +421,38 @@ exports.assignGroups = async (req, res) => {
|
|||||||
|
|
||||||
await t.commit();
|
await t.commit();
|
||||||
logActivity(req.user.user_id, 'assign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: newIds } });
|
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.`, {
|
return R.success(res, `${newIds.length} group(s) assigned.`, {
|
||||||
assigned_ids: newIds,
|
assigned_ids: newIds,
|
||||||
already_assigned_ids: existingIds,
|
already_assigned_ids: existingIds,
|
||||||
@@ -556,7 +589,7 @@ exports.createTask = async (req, res) => {
|
|||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { taskListId } = req.params;
|
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);
|
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);
|
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(
|
const task = await Task.create(
|
||||||
{
|
{
|
||||||
task_list_id: taskListId,
|
task_list_id: taskListId,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
deadline: deadline || null,
|
deadline: deadline || null,
|
||||||
|
order_index,
|
||||||
|
is_required: is_required ?? true,
|
||||||
createdBy: req.user.user_id,
|
createdBy: req.user.user_id,
|
||||||
updatedBy: 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);
|
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(
|
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 }
|
{ 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 ──────────────────────────────────────────────────
|
// ─── GET ARCHIVED TASK LISTS ──────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getArchivedTaskLists = async (req, res) => {
|
exports.getArchivedTaskLists = async (req, res) => {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const sequelize = require('../../config/db.config');
|
|||||||
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
|
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
|
||||||
const { Task } = require('../../models/task/task.mdl');
|
const { Task } = require('../../models/task/task.mdl');
|
||||||
const mdl_Users = require('../../models/users/users.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 { adminExclude } = require('../../models/task/task_completion.attributes');
|
||||||
const R = require('../../utils/response.util');
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── ARCHIVE ──────────────────────────────────────────────────────────────────
|
||||||
// DELETE /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId
|
// DELETE /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function computeDurationDays(value, unit) {
|
|||||||
|
|
||||||
exports.createPlan = async (req, res) => {
|
exports.createPlan = async (req, res) => {
|
||||||
try {
|
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)
|
if (!tier_category_id || !label || !duration_value || !price)
|
||||||
return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400);
|
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({
|
const plan = await mdl_TierPlans.create({
|
||||||
tier_category_id: category.tier_category_id,
|
tier_category_id: category.tier_category_id,
|
||||||
tier: category.slug,
|
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 });
|
const plain = plan.get({ plain: true });
|
||||||
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
|
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);
|
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
||||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
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 = {};
|
const updates = {};
|
||||||
for (const k of allowed) {
|
for (const k of allowed) {
|
||||||
if (req.body[k] !== undefined) updates[k] = req.body[k];
|
if (req.body[k] !== undefined) updates[k] = req.body[k];
|
||||||
|
|||||||
@@ -153,11 +153,12 @@ exports.getUnit = async (req, res) => {
|
|||||||
exports.createUnit = async (req, res) => {
|
exports.createUnit = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
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);
|
if (!title) return R.error(res, "Title is required.", 400);
|
||||||
|
|
||||||
const unit = await Unit.create({
|
const unit = await Unit.create({
|
||||||
title,
|
title,
|
||||||
|
subscription: subscription || null,
|
||||||
description: description ?? null,
|
description: description ?? null,
|
||||||
duration_seconds: 0,
|
duration_seconds: 0,
|
||||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
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 } });
|
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
||||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
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 (title !== undefined) unit.title = title;
|
||||||
if (description !== undefined) unit.description = description;
|
if (description !== undefined) unit.description = description;
|
||||||
|
if (subscription !== undefined) unit.subscription = subscription || null;
|
||||||
unit.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
unit.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
||||||
|
|
||||||
await unit.save();
|
await unit.save();
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl");
|
|||||||
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
|
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
|
||||||
const mdl_Product = require("../../models/courses/products.mdl");
|
const mdl_Product = require("../../models/courses/products.mdl");
|
||||||
const mdl_Category = require("../../models/courses/categories.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 {
|
const {
|
||||||
Course,
|
Course,
|
||||||
@@ -79,7 +82,9 @@ async function expireSession(session, passingScore) {
|
|||||||
return expiredAttempt;
|
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) {
|
async function buildUserContext(user_id) {
|
||||||
const activeTier = await getActiveTier(user_id);
|
const activeTier = await getActiveTier(user_id);
|
||||||
const tier = activeTier?.tier ?? 'free';
|
const tier = activeTier?.tier ?? 'free';
|
||||||
@@ -88,7 +93,19 @@ async function buildUserContext(user_id) {
|
|||||||
const tierRankMap = {};
|
const tierRankMap = {};
|
||||||
for (const c of categories) tierRankMap[c.slug] = c.rank;
|
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 ─────────────────────────────────────
|
// ─── 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.
|
// Returns false → user's tier is too low AND no valid individual purchase.
|
||||||
async function canAccessCourse(user_id, course_id) {
|
async function canAccessCourse(user_id, course_id) {
|
||||||
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
|
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);
|
const userCtx = await buildUserContext(user_id);
|
||||||
|
|
||||||
// Rank-0 slugs (default/free tier) are always accessible — resolved dynamically
|
// evaluateCourseAccess already falls back to plain rank comparison when the
|
||||||
const courseRank = userCtx.tierRankMap[requiredTier] ?? Infinity;
|
// active plan has no access_rules configured — same behavior as before for
|
||||||
if (courseRank === 0) return true;
|
// every course/plan combination that hasn't opted into the richer engine.
|
||||||
|
const { allowed } = evaluateCourseAccess(userCtx, course, userCtx.tierRankMap);
|
||||||
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
|
if (allowed) return true;
|
||||||
if (userRank >= courseRank) return true;
|
|
||||||
|
|
||||||
// Individual purchase as fallback
|
// Individual purchase as fallback
|
||||||
const product = await mdl_Product.findOne({ where: { course_id } });
|
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.
|
// content locked while letting genuinely standalone content run independently.
|
||||||
|
|
||||||
async function canAccessUnit(user_id, unit_id) {
|
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'] });
|
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) {
|
for (const link of links) {
|
||||||
if (await canAccessCourse(user_id, link.course_id)) return true;
|
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
|
// "Units → Lessons (returns all data)" — a Unit resolves all of its lesson
|
||||||
// content in one call, with or without a parent course.
|
// content in one call, with or without a parent course.
|
||||||
exports.getLessonsByUnitUuid = async (req, res) => {
|
exports.getLessonsByUnitUuid = async (req, res) => {
|
||||||
@@ -1105,7 +1178,10 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
|||||||
required: false,
|
required: false,
|
||||||
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
|
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
|
||||||
through: { attributes: ["order_index"] },
|
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",
|
model: UnitQuiz, as: "quiz",
|
||||||
@@ -1147,6 +1223,7 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
|||||||
order_index: l.order_index ?? 0,
|
order_index: l.order_index ?? 0,
|
||||||
duration_seconds: l.duration_seconds ?? 0,
|
duration_seconds: l.duration_seconds ?? 0,
|
||||||
blocks: l.page?.blocks ?? [],
|
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",
|
status: progressMap.get(String(l.lesson_id))?.status ?? "not_started",
|
||||||
completed_at: progressMap.get(String(l.lesson_id))?.completed_at ?? null,
|
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 { uuid } = req.params;
|
||||||
const lesson = await Lesson.findOne({
|
const lesson = await Lesson.findOne({
|
||||||
where: { uuid, ...notDeleted },
|
where: { uuid, ...notDeleted },
|
||||||
attributes: ["lesson_id", "uuid", "title", "description"],
|
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: LessonPage,
|
model: LessonPage,
|
||||||
@@ -1195,6 +1272,12 @@ exports.getLessonByUuid = async (req, res) => {
|
|||||||
attributes: ["blocks"],
|
attributes: ["blocks"],
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: LessonObjective,
|
||||||
|
as: "objectives",
|
||||||
|
required: false,
|
||||||
|
attributes: ["objective_id", "text", "order_index"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
model: Unit,
|
model: Unit,
|
||||||
as: "units",
|
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);
|
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 plain = lesson.toJSON();
|
||||||
const firstUnit = plain.units?.[0] ?? null;
|
const firstUnit = plain.units?.[0] ?? null;
|
||||||
const data = {
|
const data = {
|
||||||
@@ -1228,8 +1317,12 @@ exports.getLessonByUuid = async (req, res) => {
|
|||||||
uuid: plain.uuid,
|
uuid: plain.uuid,
|
||||||
title: plain.title,
|
title: plain.title,
|
||||||
description: plain.description,
|
description: plain.description,
|
||||||
|
duration_seconds: plain.duration_seconds ?? 0,
|
||||||
blocks: plain.page?.blocks ?? [],
|
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 ?? [],
|
units: plain.units ?? [],
|
||||||
};
|
};
|
||||||
return R.success(res, "Lesson retrieved.", data);
|
return R.success(res, "Lesson retrieved.", data);
|
||||||
|
|||||||
@@ -260,7 +260,8 @@ exports.streamAsset = async (req, res) => {
|
|||||||
let presignedUrl;
|
let presignedUrl;
|
||||||
try {
|
try {
|
||||||
presignedUrl = await s3.getSignedDownloadUrl(storage_key, 60);
|
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) {
|
} catch (err) {
|
||||||
console.error("[CLIENT][MEDIA][STREAM] Pre-sign failed:", err.message);
|
console.error("[CLIENT][MEDIA][STREAM] Pre-sign failed:", err.message);
|
||||||
return res.status(500).json({ message: "Could not resolve media stream." });
|
return res.status(500).json({ message: "Could not resolve media stream." });
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async function list(req, res) {
|
|||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const { count, rows } = await UserNotification.findAndCountAll({
|
const { count, rows } = await UserNotification.findAndCountAll({
|
||||||
where: { user_id: userId },
|
where: { user_id: userId, show_in_notifications: true },
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
@@ -44,7 +44,7 @@ async function unseenCount(req, res) {
|
|||||||
if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null });
|
if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null });
|
||||||
try {
|
try {
|
||||||
const count = await UserNotification.count({
|
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 });
|
return R.success(res, 'Unseen count fetched.', { count });
|
||||||
} catch (err) {
|
} 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 ────────────────────────────────────
|
// ─── PATCH /client/notifications/:id/seen ────────────────────────────────────
|
||||||
async function markSeen(req, res) {
|
async function markSeen(req, res) {
|
||||||
try {
|
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 };
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_c
|
|||||||
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
|
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
|
||||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
const mdl_Users = require('../../models/users/users.mdl');
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
|
const { QuizAttempt } = require('../../models/courses/courses.associations');
|
||||||
|
|
||||||
const { userExclude } = require('../../models/task/task.attributes');
|
const { userExclude } = require('../../models/task/task.attributes');
|
||||||
const { clientExclude } = require('../../models/task/task_completion.attributes');
|
const { clientExclude } = require('../../models/task/task_completion.attributes');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
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 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);
|
const isUUID = (v) => UUID_RE.test(v);
|
||||||
@@ -129,6 +133,150 @@ const isMember = async (userId, groupId) => {
|
|||||||
return !!membership;
|
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)
|
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
|
||||||
//
|
//
|
||||||
@@ -185,7 +333,7 @@ exports.getGroupTaskList = async (req, res) => {
|
|||||||
attributes: { exclude: userExclude },
|
attributes: { exclude: userExclude },
|
||||||
order: [['order', 'ASC']],
|
order: [['order', 'ASC']],
|
||||||
}],
|
}],
|
||||||
order: [['createdAt', 'ASC']],
|
order: [['order_index', 'ASC']],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -204,35 +352,8 @@ exports.getGroupTaskList = async (req, res) => {
|
|||||||
await hydrateReadTaskProgress(userId, readRequirements);
|
await hydrateReadTaskProgress(userId, readRequirements);
|
||||||
|
|
||||||
// ── Fetch user's completion signals for these tasks ────────────────────
|
// ── Fetch user's completion signals for these tasks ────────────────────
|
||||||
const [completions, linkVisits, progressRows] = await Promise.all([
|
const allRequirements = tasks.flatMap((task) => task.requirements ?? []);
|
||||||
taskIds.length
|
const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements);
|
||||||
? 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 now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -240,20 +361,8 @@ exports.getGroupTaskList = async (req, res) => {
|
|||||||
const bucketedTasks = tasks.map((task) => {
|
const bucketedTasks = tasks.map((task) => {
|
||||||
const requirements = task.requirements ?? [];
|
const requirements = task.requirements ?? [];
|
||||||
|
|
||||||
const allRequirementsDone = requirements.length > 0 && requirements.every((r) => {
|
const allRequirementsDone = requirements.length > 0 &&
|
||||||
switch (r.type) {
|
requirements.every((r) => isRequirementDone(r, signals));
|
||||||
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 has_completed = allRequirementsDone;
|
const has_completed = allRequirementsDone;
|
||||||
|
|
||||||
@@ -333,7 +442,7 @@ exports.getGroupTaskLists = async (req, res) => {
|
|||||||
order: [['order', 'ASC']],
|
order: [['order', 'ASC']],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'ASC']],
|
order: [['order_index', 'ASC']],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
attributes: { exclude: userExclude },
|
attributes: { exclude: userExclude },
|
||||||
@@ -357,33 +466,8 @@ exports.getGroupTaskLists = async (req, res) => {
|
|||||||
await hydrateReadTaskProgress(userId, readRequirements);
|
await hydrateReadTaskProgress(userId, readRequirements);
|
||||||
|
|
||||||
// ── Fetch user's completion signals for these tasks ────────────────────
|
// ── Fetch user's completion signals for these tasks ────────────────────
|
||||||
const [completions, linkVisits, progressRows] = await Promise.all([
|
const allRequirements = allTasks.flatMap((task) => task.requirements ?? []);
|
||||||
taskIds.length
|
const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements);
|
||||||
? 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 now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -392,20 +476,7 @@ exports.getGroupTaskLists = async (req, res) => {
|
|||||||
const requirements = task.requirements ?? [];
|
const requirements = task.requirements ?? [];
|
||||||
if (requirements.length === 0) return false; // vacuously not done
|
if (requirements.length === 0) return false; // vacuously not done
|
||||||
|
|
||||||
return requirements.every((r) => {
|
return requirements.every((r) => isRequirementDone(r, signals));
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Bucket each task list based on per-task has_completed ───────────────
|
// ── Bucket each task list based on per-task has_completed ───────────────
|
||||||
@@ -576,7 +647,7 @@ exports.submitTask = async (req, res) => {
|
|||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { groupId, taskListId, taskId } = req.params;
|
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);
|
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); }
|
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 });
|
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 (!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();
|
await t.rollback();
|
||||||
return R.error(res, 'At least one file is required to submit.', 400);
|
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
|
// Validate file entries have required fields
|
||||||
const invalid = files.some((f) => !f.file_url || !f.file_name);
|
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);
|
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) {
|
if (uploadRequirement) {
|
||||||
// ── max_file_count ───────────────────────────────────────────────────
|
// ── max_file_count ───────────────────────────────────────────────────
|
||||||
const maxFiles = uploadRequirement.max_file_count;
|
const maxFiles = uploadRequirement.max_file_count;
|
||||||
@@ -641,22 +729,25 @@ exports.submitTask = async (req, res) => {
|
|||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
user_id: req.user.user_id,
|
user_id: req.user.user_id,
|
||||||
note: note || null,
|
note: note || null,
|
||||||
|
response_text: response_text || null,
|
||||||
submitted_at: new Date(),
|
submitted_at: new Date(),
|
||||||
createdBy: req.user.user_id,
|
createdBy: req.user.user_id,
|
||||||
updatedBy: req.user.user_id,
|
updatedBy: req.user.user_id,
|
||||||
}, { transaction: t });
|
}, { transaction: t });
|
||||||
|
|
||||||
const fileRows = files.map((f) => ({
|
if (files.length) {
|
||||||
completion_id: completion.completion_id,
|
const fileRows = files.map((f) => ({
|
||||||
file_url: f.file_url,
|
completion_id: completion.completion_id,
|
||||||
file_name: f.file_name,
|
file_url: f.file_url,
|
||||||
file_size: f.file_size ?? null,
|
file_name: f.file_name,
|
||||||
mime_type: f.mime_type ?? null,
|
file_size: f.file_size ?? null,
|
||||||
storage_key: f.storage_key ?? null,
|
mime_type: f.mime_type ?? null,
|
||||||
createdBy: req.user.user_id,
|
storage_key: f.storage_key ?? null,
|
||||||
updatedBy: req.user.user_id,
|
createdBy: req.user.user_id,
|
||||||
}));
|
updatedBy: req.user.user_id,
|
||||||
await TaskCompletionFile.bulkCreate(fileRows, { transaction: t });
|
}));
|
||||||
|
await TaskCompletionFile.bulkCreate(fileRows, { transaction: t });
|
||||||
|
}
|
||||||
|
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
|
||||||
@@ -675,6 +766,10 @@ exports.submitTask = async (req, res) => {
|
|||||||
entityId: Number(taskId),
|
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);
|
return R.success(res, 'Task submitted successfully.', full, 201);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ const { Op } = require('sequelize');
|
|||||||
const sequelize = require('../../config/db.config');
|
const sequelize = require('../../config/db.config');
|
||||||
|
|
||||||
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
|
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 { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
|
||||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
const { Course } = require('../../models/courses/courses.mdl');
|
const { Course } = require('../../models/courses/courses.mdl');
|
||||||
const CourseAssessment = require('../../models/courses/course_assessment.mdl');
|
const CourseAssessment = require('../../models/courses/course_assessment.mdl');
|
||||||
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
|
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
|
||||||
|
const UnitQuiz = require('../../models/courses/unit_quiz.mdl');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
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);
|
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([
|
const [linkVisits, progress] = await Promise.all([
|
||||||
TaskLinkVisit.findAll({
|
TaskLinkVisit.findAll({
|
||||||
where: { task_id: taskId, user_id: req.user.user_id },
|
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) {
|
} catch (err) {
|
||||||
console.error('[CLIENT][GET TASK PROGRESS]', err);
|
console.error('[CLIENT][GET TASK PROGRESS]', err);
|
||||||
return R.error(res, 'Could not retrieve task progress.', 500);
|
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);
|
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 now = new Date();
|
||||||
|
|
||||||
const [record, created] = await TaskLinkVisit.upsert(
|
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(
|
return R.success(
|
||||||
res,
|
res,
|
||||||
created ? 'Link visited.' : 'Link visit updated.',
|
created ? 'Link visited.' : 'Link visit updated.',
|
||||||
@@ -302,6 +349,7 @@ exports.updateProgress = async (req, res) => {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const userId = req.user.user_id;
|
const userId = req.user.user_id;
|
||||||
|
const wasComplete = await checkTaskCompletion(userId, taskId);
|
||||||
|
|
||||||
// ── Direct UPSERT for read_unit / read_course ─────────────────────────
|
// ── Direct UPSERT for read_unit / read_course ─────────────────────────
|
||||||
if (requirement.type === 'read_unit' || requirement.type === 'read_course') {
|
if (requirement.type === 'read_unit' || requirement.type === 'read_course') {
|
||||||
@@ -323,6 +371,9 @@ exports.updateProgress = async (req, res) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
if (!wasComplete && await checkTaskCompletion(userId, taskId)) {
|
||||||
|
fireTaskCompletedEvent(userId, taskId); // fire-and-forget
|
||||||
|
}
|
||||||
return R.success(res, 'Progress updated.', {
|
return R.success(res, 'Progress updated.', {
|
||||||
requirement_id: requirementId,
|
requirement_id: requirementId,
|
||||||
reference_id,
|
reference_id,
|
||||||
@@ -403,6 +454,9 @@ exports.updateProgress = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
if (!wasComplete && await checkTaskCompletion(userId, taskId)) {
|
||||||
|
fireTaskCompletedEvent(userId, taskId); // fire-and-forget
|
||||||
|
}
|
||||||
return R.success(res, 'Progress updated.', {
|
return R.success(res, 'Progress updated.', {
|
||||||
requirement_id: requirementId,
|
requirement_id: requirementId,
|
||||||
reference_id,
|
reference_id,
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ exports.getPlans = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const plans = await mdl_TierPlans.findAll({
|
const plans = await mdl_TierPlans.findAll({
|
||||||
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
|
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: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Course,
|
model: Course,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* Description: Standalone Unit / Lesson consumption — the junction revamp lets
|
* Description: Standalone Unit / Lesson consumption — the junction revamp lets
|
||||||
* learners run Units and Lessons outside any Course:
|
* 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 → unit metadata (shared handler)
|
||||||
* GET /client/units/:uuid/lessons → unit + ALL lesson data (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
|
* 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
|
* be able to access at least one attached course. Lessons resolve through
|
||||||
* their parent units the same way.
|
* 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)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jul. 7, 2026
|
* Date Created: Jul. 7, 2026
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
@@ -53,11 +61,17 @@ function sanitizeQuestions(questions = []) {
|
|||||||
|
|
||||||
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
|
// ─── 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) => {
|
exports.getUnits = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = await sequelize.query(`
|
const rows = await sequelize.query(`
|
||||||
SELECT
|
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
|
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
|
||||||
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
|
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,
|
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
|
WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id
|
||||||
FROM units u
|
FROM units u
|
||||||
WHERE u."deletedAt" IS NULL
|
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
|
ORDER BY u.title ASC
|
||||||
`, { type: sequelize.QueryTypes.SELECT });
|
`, { type: sequelize.QueryTypes.SELECT });
|
||||||
|
|
||||||
@@ -89,11 +108,12 @@ exports.getUnits = async (req, res) => {
|
|||||||
coursesByUnit.set(row.unit_id, list);
|
coursesByUnit.set(row.unit_id, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
// is_locked mirrors canAccessUnit: standalone units are open, attached units
|
// is_locked mirrors canAccessUnit: a unit with its own subscription or at
|
||||||
// need at least one accessible course.
|
// least one attached course needs an access check; a fully open standalone
|
||||||
|
// unit (no subscription, no course links) is never locked.
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const row of rows) {
|
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))
|
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
|
||||||
: false;
|
: false;
|
||||||
result.push({ ...row, courses: coursesByUnit.get(row.unit_id) ?? [], is_locked });
|
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 ─────────────────────────────────────────────────────
|
// ─── STANDALONE UNIT QUIZ ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getUnitQuiz = async (req, res) => {
|
exports.getUnitQuiz = async (req, res) => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
const userNotifications = require('./jobs/user_notifications.cron');
|
const userNotifications = require('./jobs/user_notifications.cron');
|
||||||
const issueCertificates = require('./jobs/issue_certificates.cron');
|
const issueCertificates = require('./jobs/issue_certificates.cron');
|
||||||
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
||||||
|
const taskDueSoon = require('./jobs/task_due_soon.cron');
|
||||||
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
||||||
|
|
||||||
// ─── Registry — add future client-side cron jobs here ────────────────────────
|
// ─── Registry — add future client-side cron jobs here ────────────────────────
|
||||||
@@ -28,6 +29,7 @@ const jobs = [
|
|||||||
userNotifications,
|
userNotifications,
|
||||||
issueCertificates,
|
issueCertificates,
|
||||||
expireUserTiers,
|
expireUserTiers,
|
||||||
|
taskDueSoon,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Boot all registered client-side jobs ─────────────────────────────────────
|
// ─── Boot all registered client-side jobs ─────────────────────────────────────
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -29,6 +29,12 @@ const ENRICHERS = {
|
|||||||
deadline: fmtDate(data.deadline),
|
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) => {
|
welcome: (data) => {
|
||||||
const greeting = data.accType === 'admin'
|
const greeting = data.accType === 'admin'
|
||||||
? 'Welcome, Administrator!'
|
? 'Welcome, Administrator!'
|
||||||
|
|||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
+30
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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'))`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
+42
@@ -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'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@ const Lesson = sequelize.define("Lesson", {
|
|||||||
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0 },
|
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 },
|
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
|
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, // computed from blocks on save
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const Unit = sequelize.define("Unit", {
|
|||||||
unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: false },
|
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 },
|
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 },
|
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 },
|
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 },
|
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 2, filterable: false },
|
||||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
|
createdBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ const AdminNotification = sequelize.define('AdminNotification', {
|
|||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
allowNull: false,
|
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: {
|
data: {
|
||||||
type: DataTypes.JSONB,
|
type: DataTypes.JSONB,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
|
|||||||
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 },
|
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 },
|
||||||
message: { type: DataTypes.TEXT, allowNull: false, label: "Message", order: 2 },
|
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 ────────────────────────────────────────────────────────────
|
// ─── Targeting ────────────────────────────────────────────────────────────
|
||||||
target_type: {
|
target_type: {
|
||||||
type: DataTypes.ENUM("admin", "user", "both", "task_list", "course", "tier_plan"),
|
type: DataTypes.ENUM("admin", "user", "both", "task_list", "course", "tier_plan"),
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ const UserNotification = sequelize.define('UserNotification', {
|
|||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
allowNull: false,
|
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: {
|
data: {
|
||||||
type: DataTypes.JSONB,
|
type: DataTypes.JSONB,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
+12
-3
@@ -41,6 +41,8 @@ const Task = sequelize.define('Task', {
|
|||||||
name: { type: DataTypes.STRING, allowNull: false, validate: { notEmpty: true }, order: 1, filterable: true },
|
name: { type: DataTypes.STRING, allowNull: false, validate: { notEmpty: true }, order: 1, filterable: true },
|
||||||
description: { type: DataTypes.TEXT, allowNull: true, hidden: true, filterable: false },
|
description: { type: DataTypes.TEXT, allowNull: true, hidden: true, filterable: false },
|
||||||
deadline: { type: DataTypes.DATE, allowNull: true, order: 2, filterable: true },
|
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 },
|
status: { type: DataTypes.ENUM('pending', 'in_progress', 'completed', 'overdue'), defaultValue: 'pending', allowNull: false, filterable: true },
|
||||||
|
|
||||||
// ── Audit trails ────────────────────────────────────────────────────────
|
// ── Audit trails ────────────────────────────────────────────────────────
|
||||||
@@ -56,7 +58,7 @@ const Task = sequelize.define('Task', {
|
|||||||
const TaskRequirement = sequelize.define('TaskRequirement', {
|
const TaskRequirement = sequelize.define('TaskRequirement', {
|
||||||
requirement_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
|
requirement_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
|
||||||
task_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' } },
|
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 ──────────────────────────────────────────────────────────
|
// ── visit_link ──────────────────────────────────────────────────────────
|
||||||
link_url: { type: DataTypes.STRING, allowNull: true, filterable: false },
|
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 },
|
allowed_file_types: { type: DataTypes.JSONB, allowNull: true, filterable: false },
|
||||||
max_file_count: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1, filterable: false },
|
max_file_count: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1, filterable: false },
|
||||||
|
|
||||||
// ── read_course / read_unit / read_lesson ────────────────────────────────
|
// ── read_course / read_unit / read_lesson / pass_quiz ─────────────────────
|
||||||
reference_id: { type: DataTypes.UUID, allowNull: true, comment: 'course_id | unit_id | lesson_id depending on type', filterable: false },
|
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 },
|
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 },
|
order: { type: DataTypes.INTEGER, defaultValue: 0, filterable: true },
|
||||||
|
|
||||||
// ── Audit trails ────────────────────────────────────────────────────────
|
// ── Audit trails ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ const TaskCompletion = sequelize.define('TaskCompletion', {
|
|||||||
comment: 'Optional note the user attaches when submitting.',
|
comment: 'Optional note the user attaches when submitting.',
|
||||||
order: 1,
|
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: {
|
submitted_at: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
defaultValue: DataTypes.NOW,
|
defaultValue: DataTypes.NOW,
|
||||||
@@ -49,6 +55,18 @@ const TaskCompletion = sequelize.define('TaskCompletion', {
|
|||||||
order: 2,
|
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 ────────────────────────────────────────────────────────
|
// ── Audit trails ────────────────────────────────────────────────────────
|
||||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, order: 6 },
|
createdBy: { type: DataTypes.BIGINT, allowNull: true, order: 6 },
|
||||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, order: 7 },
|
updatedBy: { type: DataTypes.BIGINT, allowNull: true, order: 7 },
|
||||||
|
|||||||
@@ -99,10 +99,10 @@ const TaskProgress = sequelize.define('TaskProgress', {
|
|||||||
reference_id: {
|
reference_id: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
allowNull: false,
|
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: {
|
||||||
type: DataTypes.ENUM('read_course', 'read_unit', 'read_lesson'),
|
type: DataTypes.ENUM('read_course', 'read_unit', 'read_lesson', 'pass_quiz'),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const mdl_TierPlans = sequelize.define('TierPlan', {
|
|||||||
tier: { type: DataTypes.STRING(50), allowNull: false, label: 'Tier' },
|
tier: { type: DataTypes.STRING(50), allowNull: false, label: 'Tier' },
|
||||||
label: { type: DataTypes.STRING(100), allowNull: false, label: 'Plan Label' },
|
label: { type: DataTypes.STRING(100), allowNull: false, label: 'Plan Label' },
|
||||||
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description' },
|
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_days: { type: DataTypes.FLOAT, allowNull: false, label: 'Duration (Days)' },
|
||||||
duration_unit: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'day', label: 'Duration Unit' },
|
duration_unit: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'day', label: 'Duration Unit' },
|
||||||
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' },
|
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' },
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ router.use('/notifications', notificationRoutes);
|
|||||||
router.use('/notification-broadcasts', notificationBroadcastRoutes);
|
router.use('/notification-broadcasts', notificationBroadcastRoutes);
|
||||||
router.use('/notification-settings', notificationSettingsRoutes);
|
router.use('/notification-settings', notificationSettingsRoutes);
|
||||||
router.use('/notification-templates', notificationTemplatesRoutes);
|
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('/media', mediaRoutes);
|
||||||
router.use('/achievements', achievementsRoutes);
|
router.use('/achievements', achievementsRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ router.use(authenticate, requireAdmin(), adminLimiter);
|
|||||||
|
|
||||||
router.get("/", ctrl.getCourses);
|
router.get("/", ctrl.getCourses);
|
||||||
router.post("/", ctrl.createCourse);
|
router.post("/", ctrl.createCourse);
|
||||||
|
router.post("/full", ctrl.createCourseFull); // one-shot Add Course wizard submit (course + roadmap + rewards)
|
||||||
|
|
||||||
// ── static segments first ────────────────────────────────────────────────────
|
// ── static segments first ────────────────────────────────────────────────────
|
||||||
router.get("/field-values", ctrl.getCourseFieldValues);
|
router.get("/field-values", ctrl.getCourseFieldValues);
|
||||||
@@ -25,6 +26,7 @@ router.get("/flat", ctrl.getCoursesFlat);
|
|||||||
router.get("/by-subscription", ctrl.getCoursesBySubscription);
|
router.get("/by-subscription", ctrl.getCoursesBySubscription);
|
||||||
router.get("/units-flat", ctrl.getUnitsFlat);
|
router.get("/units-flat", ctrl.getUnitsFlat);
|
||||||
router.get("/lessons-flat", ctrl.getLessonsFlat);
|
router.get("/lessons-flat", ctrl.getLessonsFlat);
|
||||||
|
router.get("/quizzes-flat", ctrl.getQuizzesFlat);
|
||||||
|
|
||||||
// ── then :courseId ────────────────────────────────────────────────────────────
|
// ── then :courseId ────────────────────────────────────────────────────────────
|
||||||
router.get("/archives/:courseId", ctrl.getArchivedCourse);
|
router.get("/archives/:courseId", ctrl.getArchivedCourse);
|
||||||
|
|||||||
@@ -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-archive", sensitiveOpsLimiter, controller.bulkArchiveTasks);
|
||||||
router.post("/:taskListId/tasks/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTasks);
|
router.post("/:taskListId/tasks/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTasks);
|
||||||
router.post("/:taskListId/tasks/bulk-delete", sensitiveOpsLimiter, controller.bulkPermanentlyDeleteTasks);
|
router.post("/:taskListId/tasks/bulk-delete", sensitiveOpsLimiter, controller.bulkPermanentlyDeleteTasks);
|
||||||
|
router.patch("/:taskListId/tasks/order", sensitiveOpsLimiter, controller.reorderTasks);
|
||||||
|
|
||||||
// ─── Tasks (single) ───────────────────────────────────────────────────────────
|
// ─── Tasks (single) ───────────────────────────────────────────────────────────
|
||||||
router.get("/:taskListId/tasks/:taskId", controller.getTask);
|
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-archive", sensitiveOpsLimiter, completionController.bulkArchiveCompletions);
|
||||||
router.post("/:taskListId/tasks/:taskId/completions/bulk-restore", sensitiveOpsLimiter, completionController.bulkRestoreCompletions);
|
router.post("/:taskListId/tasks/:taskId/completions/bulk-restore", sensitiveOpsLimiter, completionController.bulkRestoreCompletions);
|
||||||
router.get("/:taskListId/tasks/:taskId/completions/:completionId", completionController.getCompletion);
|
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.delete("/:taskListId/tasks/:taskId/completions/:completionId", sensitiveOpsLimiter, completionController.archiveCompletion);
|
||||||
router.patch("/:taskListId/tasks/:taskId/completions/:completionId/restore", sensitiveOpsLimiter, completionController.restoreCompletion);
|
router.patch("/:taskListId/tasks/:taskId/completions/:completionId/restore", sensitiveOpsLimiter, completionController.restoreCompletion);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ router.get('/uuid/:uuid', ctrl.getCourseByUuid);
|
|||||||
router.get('/unit/uuid/:uuid/lessons', ctrl.getLessonsByUnitUuid);
|
router.get('/unit/uuid/:uuid/lessons', ctrl.getLessonsByUnitUuid);
|
||||||
router.get('/unit/uuid/:uuid', ctrl.getUnitByUuid);
|
router.get('/unit/uuid/:uuid', ctrl.getUnitByUuid);
|
||||||
router.get('/lesson/uuid/:uuid', ctrl.getLessonByUuid);
|
router.get('/lesson/uuid/:uuid', ctrl.getLessonByUuid);
|
||||||
|
router.get('/quiz/uuid/:uuid', ctrl.getQuizByUuid);
|
||||||
|
|
||||||
// Courses
|
// Courses
|
||||||
router.get('/', ctrl.getCourses);
|
router.get('/', ctrl.getCourses);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Type of Program: Router
|
* Type of Program: Router
|
||||||
* Description: Standalone Lesson consumption — each Lesson runs independently.
|
* Description: Standalone Lesson consumption — each Lesson runs independently.
|
||||||
*
|
*
|
||||||
|
* GET /client/lessons → learner-facing lesson library
|
||||||
* GET /client/lessons/:uuid → lesson with full block content
|
* GET /client/lessons/:uuid → lesson with full block content
|
||||||
* POST /client/lessons/:uuid/progress → standalone reading progress
|
* POST /client/lessons/:uuid/progress → standalone reading progress
|
||||||
* (course NULL; body.unit_uuid optional)
|
* (course NULL; body.unit_uuid optional)
|
||||||
@@ -14,6 +15,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../../controllers/client/units.controller');
|
const ctrl = require('../../controllers/client/units.controller');
|
||||||
|
|
||||||
|
router.get('/', ctrl.getLessons);
|
||||||
router.get('/:uuid', ctrl.getLessonByUuid);
|
router.get('/:uuid', ctrl.getLessonByUuid);
|
||||||
router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress);
|
router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress);
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,11 @@
|
|||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
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('/', list);
|
||||||
router.get('/unseen', unseenCount);
|
router.get('/unseen', unseenCount);
|
||||||
|
router.get('/sticky', stickyAnnouncement);
|
||||||
router.patch('/seen-all', markAllSeen);
|
router.patch('/seen-all', markAllSeen);
|
||||||
router.patch('/:id/seen', markSeen);
|
router.patch('/:id/seen', markSeen);
|
||||||
router.delete('/clear-all', clearAll);
|
router.delete('/clear-all', clearAll);
|
||||||
|
|||||||
@@ -105,6 +105,16 @@ async function onPerfectQuiz(user_id, quiz_id) {
|
|||||||
await grantAchievement(user_id, 'perfect_quiz_score', { 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 */
|
/** Call after profile is fully filled out */
|
||||||
async function onProfileCompleted(user_id) {
|
async function onProfileCompleted(user_id) {
|
||||||
await grantAchievement(user_id, 'profile_completed');
|
await grantAchievement(user_id, 'profile_completed');
|
||||||
@@ -151,6 +161,8 @@ module.exports = {
|
|||||||
onPerfectQuiz,
|
onPerfectQuiz,
|
||||||
onProfileCompleted,
|
onProfileCompleted,
|
||||||
onReferral,
|
onReferral,
|
||||||
|
onTaskCompleted,
|
||||||
|
onTaskListCompleted,
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
adminGrantAchievement,
|
adminGrantAchievement,
|
||||||
|
|||||||
+11
-10
@@ -159,18 +159,18 @@ async function deleteFile(key) {
|
|||||||
// For server-side reads only (e.g. media.controller.js streamAsset piping
|
// 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.
|
// 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
|
// Must NOT be signed against S3_PUBLIC_URL when Garage is in use: that host is
|
||||||
// garage-anon-proxy, which re-signs every request itself (header-based SigV4,
|
// fronted by garage-anon-proxy, which re-signs every request itself (header-based
|
||||||
// real credentials) regardless of any query-string signature already present.
|
// SigV4, real credentials) regardless of any query-string signature already
|
||||||
// A presigned URL arriving there collides with the proxy's own signature and
|
// present. A presigned URL arriving there collides with the proxy's own
|
||||||
// Garage rejects the request (400 "Header `x-amz-date` should be signed").
|
// signature and Garage rejects the request (400 "Header `x-amz-date` should
|
||||||
// See getPublicUrl() below for the browser-facing equivalent.
|
// be signed").
|
||||||
// TEMPORARY:
|
//
|
||||||
// The problem is this line do not tell for each of development machines
|
// Uses the internal `s3` client (always S3_ENDPOINT) when available; falls back
|
||||||
// which ones should be called whether it's for PUBLIC_URL or LOCAL_ENDPOINT itself.
|
// to getPublicClient() for external S3-compatible services without Garage.
|
||||||
//
|
//
|
||||||
async function getSignedDownloadUrl(key, expiresInSeconds = 3600) {
|
async function getSignedDownloadUrl(key, expiresInSeconds = 3600) {
|
||||||
const client = getPublicClient();
|
const client = process.env.S3_ENDPOINT ? s3 : getPublicClient();
|
||||||
|
|
||||||
return getSignedUrl(
|
return getSignedUrl(
|
||||||
client,
|
client,
|
||||||
@@ -183,6 +183,7 @@ async function getSignedDownloadUrl(key, expiresInSeconds = 3600) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── getPublicUrl ─────────────────────────────────────────────────────────────
|
// ─── getPublicUrl ─────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Browser-facing pre-signed GET URL, signed against S3_PUBLIC_URL. Safe again
|
// Browser-facing pre-signed GET URL, signed against S3_PUBLIC_URL. Safe again
|
||||||
|
|||||||
@@ -47,13 +47,20 @@ function buildWhere(filters = [], allowedFields = new Set()) {
|
|||||||
* @param {Array<{ id: string, desc: boolean }>} sort
|
* @param {Array<{ id: string, desc: boolean }>} sort
|
||||||
* @returns {Array} Sequelize order clause
|
* @returns {Array} Sequelize order clause
|
||||||
*/
|
*/
|
||||||
function buildOrder(sort = [], allowedFields = new Set()) {
|
function buildOrder(sort = [], allowedFields = new Set(), computedFields = new Set()) {
|
||||||
const order = [];
|
const order = [];
|
||||||
|
|
||||||
for (const { id, desc } of sort) {
|
for (const { id, desc } of sort) {
|
||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
if (allowedFields.size && !allowedFields.has(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 ─────────────────────
|
// ─── ENUM fields — use CASE for custom sort order ─────────────────────
|
||||||
if (ENUM_SORT_ORDER[id]) {
|
if (ENUM_SORT_ORDER[id]) {
|
||||||
const sequence = desc
|
const sequence = desc
|
||||||
@@ -88,12 +95,16 @@ function buildOrder(sort = [], allowedFields = new Set()) {
|
|||||||
* @param {Array} sort
|
* @param {Array} sort
|
||||||
* @returns {{ where: Object, order: Array }}
|
* @returns {{ where: Object, order: Array }}
|
||||||
*/
|
*/
|
||||||
function buildQuery(filters = [], sort = [], allowedFields = []) {
|
function buildQuery(filters = [], sort = [], allowedFields = [], computedFields = []) {
|
||||||
const fieldSet = new Set(allowedFields);
|
const fieldSet = new Set(allowedFields);
|
||||||
|
const computedSet = new Set(computedFields);
|
||||||
|
const orderFieldSet = new Set([...allowedFields, ...computedFields]);
|
||||||
|
|
||||||
return {
|
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),
|
where: buildWhere(filters, fieldSet),
|
||||||
order: buildOrder(sort, fieldSet),
|
order: buildOrder(sort, orderFieldSet, computedSet),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,9 @@ async function paginate(model, req, {
|
|||||||
|
|
||||||
const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas, context });
|
const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas, context });
|
||||||
const ALLOWED_FIELDS = attributes.map((a) => a.field);
|
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
|
// Build attribute includes: jsonb + audit subqueries + any extra from findOptions
|
||||||
const baseIncludes = jsonbAttr ? [jsonbAttr] : [];
|
const baseIncludes = jsonbAttr ? [jsonbAttr] : [];
|
||||||
|
|||||||
Reference in New Issue
Block a user