units,lesson as standalone

This commit is contained in:
2026-07-10 11:44:46 +08:00
parent 86fba50b95
commit e1ffdab190
46 changed files with 1463 additions and 197 deletions
+222
View File
@@ -169,6 +169,169 @@ exports.createCourse = async (req, res) => {
}
};
// POST /full — single-shot Add Course wizard submit. Creates the course,
// its objectives/badge/achievement, and the whole units/lessons roadmap
// (attaching existing library items and/or creating new ones) in one
// transaction, so the wizard fires exactly one write instead of one per
// step/click.
//
// Body:
// { title, description, order_index, course_code, level, subscription,
// objectives: [string], category_ids: [id],
// badge_color, badge_asset_id, badge_image_url, achievement_keys: [key],
// units: [{
// unit_id?, // attach existing library unit
// title, description, // create new unit when unit_id is absent
// lessons: [{
// lesson_id?, // attach existing library lesson
// title, description, objectives, // create new lesson when lesson_id is absent
// }],
// }],
// createdBy }
exports.createCourseFull = async (req, res) => {
const t = await sequelize.transaction();
try {
const {
title, description, order_index,
course_code, level, subscription,
objectives = [],
category_ids = [],
achievement_keys = [],
badge_color, badge_asset_id, badge_image_url,
units = [],
createdBy,
} = req.body;
if (!title) return R.error(res, "Title is required.", 400);
for (const unit of units) {
if (!unit.unit_id && !unit.title) {
await t.rollback();
return R.error(res, "Each new unit needs a title.", 400);
}
for (const lesson of unit.lessons ?? []) {
if (!lesson.lesson_id && !lesson.title) {
await t.rollback();
return R.error(res, "Each new lesson needs a title.", 400);
}
}
}
const course = await Course.create({
title,
description: description ?? null,
order_index: order_index ?? 0,
course_code: course_code ?? null,
level: level ?? null,
subscription: subscription ?? "free",
duration_seconds: 0,
badge_color: badge_color ?? "purple",
badge_asset_id: badge_asset_id ?? null,
badge_image_url: badge_image_url ?? null,
createdBy: createdBy ?? null,
}, { transaction: t });
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t);
await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t);
if (achievement_keys.length) {
await CourseAchievement.bulkCreate(
achievement_keys.slice(0, 1).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
{ transaction: t },
);
}
const by = createdBy ?? req.user?.user_id ?? null;
const touchedUnitIds = [];
for (let i = 0; i < units.length; i++) {
const unitInput = units[i];
let unit;
if (unitInput.unit_id) {
unit = await Unit.findOne({ where: { unit_id: unitInput.unit_id, ...notDeleted }, transaction: t });
if (!unit) {
await t.rollback();
return R.error(res, "One or more units were not found.", 404);
}
const otherLink = await CourseUnit.findOne({ where: { unit_id: unitInput.unit_id }, transaction: t });
if (otherLink) {
await t.rollback();
return R.error(res, `"${unit.title}" is already attached to another course.`, 409);
}
} else {
unit = await Unit.create({
title: unitInput.title,
description: unitInput.description ?? null,
duration_seconds: 0,
createdBy: by,
}, { transaction: t });
}
await CourseUnit.create({
course_id: course.course_id,
unit_id: unit.unit_id,
order_index: i,
createdBy: by,
}, { transaction: t });
const lessons = unitInput.lessons ?? [];
if (lessons.length) touchedUnitIds.push(unit.unit_id);
let lessonOrder = unitInput.unit_id ? await nextOrderIndex(UnitLesson, { unit_id: unit.unit_id }, t) : 0;
for (const lessonInput of lessons) {
let lesson;
if (lessonInput.lesson_id) {
lesson = await Lesson.findOne({ where: { lesson_id: lessonInput.lesson_id, ...notDeleted }, transaction: t });
if (!lesson) {
await t.rollback();
return R.error(res, "One or more lessons were not found.", 404);
}
const already = await getUnitLessonLink(unit.unit_id, lessonInput.lesson_id, t);
if (already) continue; // already attached to this unit, nothing to do
} else {
lesson = await Lesson.create({
title: lessonInput.title,
description: lessonInput.description ?? null,
duration_seconds: 0,
createdBy: by,
}, { transaction: t });
await LessonPage.create({
lesson_id: lesson.lesson_id,
blocks: [],
createdBy: by,
}, { transaction: t });
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, lessonInput.objectives ?? [], t);
}
await UnitLesson.create({
unit_id: unit.unit_id,
lesson_id: lesson.lesson_id,
order_index: lessonOrder++,
createdBy: by,
}, { transaction: t });
}
}
await t.commit();
try {
for (const unitId of touchedUnitIds) await recomputeUnitDuration(unitId);
await recomputeCourseDuration(course.course_id);
} catch (durErr) { console.error("[COURSE][CREATE FULL][DURATION]", durErr); }
logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title, units: units.length } });
return R.success(res, "Course created.", { data: course }, 201);
} catch (err) {
await t.rollback();
console.error("[COURSE][CREATE FULL]", err);
return R.error(res, "Could not create course.", 500);
}
};
exports.updateCourse = async (req, res) => {
const t = await sequelize.transaction();
try {
@@ -521,6 +684,19 @@ exports.attachUnits = async (req, res) => {
const existingSet = new Set(existing.map((r) => String(r.unit_id)));
const toAttach = unit_ids.filter((id) => !existingSet.has(String(id)));
// A unit may only belong to one course at a time — reject the whole
// batch if any candidate is already linked elsewhere, rather than
// silently skipping (the admin should see and deselect it).
if (toAttach.length) {
const otherCourseLinks = await CourseUnit.findAll({ where: { unit_id: toAttach }, transaction: t });
if (otherCourseLinks.length) {
await t.rollback();
const blockedSet = new Set(otherCourseLinks.map((l) => String(l.unit_id)));
const blockedTitles = units.filter((u) => blockedSet.has(String(u.unit_id))).map((u) => u.title);
return R.error(res, `${blockedTitles.join(", ")} ${blockedTitles.length !== 1 ? "are" : "is"} already attached to another course.`, 409);
}
}
let order = await nextOrderIndex(CourseUnit, { course_id: courseId }, t);
await CourseUnit.bulkCreate(
toAttach.map((unit_id) => ({
@@ -631,6 +807,12 @@ exports.createUnit = async (req, res) => {
await t.rollback();
return R.error(res, "Unit is already attached to this course.", 409);
}
// A unit may only belong to one course at a time.
const otherLink = await CourseUnit.findOne({ where: { unit_id }, transaction: t });
if (otherLink) {
await t.rollback();
return R.error(res, "This unit is already attached to another course.", 409);
}
} else {
if (!title) return R.error(res, "Title is required.", 400);
unit = await Unit.create({
@@ -2119,6 +2301,46 @@ exports.getLessonsFlat = async (req, res) => {
return R.error(res, "Could not retrieve lessons.", 500);
}
};
// One row per unit quiz (a quiz is always unit-scoped, unit_id unique on
// unit_quizzes). Used by the pass_quiz task requirement picker — same
// "no content yet" convention as read_*: question_count === 0 is flagged
// the same way duration_seconds === 0 is for content requirements.
exports.getQuizzesFlat = async (req, res) => {
try {
const rows = await sequelize.query(`
SELECT
q.uuid, q.title, u.title AS unit_title,
COALESCE(c.title, '') AS course_title,
COALESCE(c.subscription, 'free') AS subscription,
(SELECT CAST(COUNT(*) AS INTEGER) FROM quiz_questions qq
WHERE qq.quiz_id = q.quiz_id AND qq."deletedAt" IS NULL) AS question_count
FROM unit_quizzes q
JOIN units u ON u.unit_id = q.unit_id AND u."deletedAt" IS NULL
LEFT JOIN course_units cu ON cu.unit_id = u.unit_id
LEFT JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE q."deletedAt" IS NULL
ORDER BY course_title ASC, u.title ASC, q.title ASC
`, { type: sequelize.QueryTypes.SELECT });
const data = rows.map((r) => ({
uuid: r.uuid,
title: r.title || `${r.unit_title} Quiz`,
unit_title: r.unit_title ?? "",
course_title: r.course_title ?? "",
subscription: r.subscription ?? "free",
question_count: Number(r.question_count ?? 0),
// duration_seconds doesn't apply to quizzes — ContentPicker's "no
// content" check keys off duration_seconds === 0, so surface the same
// signal under that name rather than adding a second code path.
duration_seconds: Number(r.question_count ?? 0),
}));
return R.success(res, "Quizzes retrieved.", data);
} catch (err) {
console.error("[QUIZ][GET FLAT]", err);
return R.error(res, "Could not retrieve quizzes.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE INSTRUCTORS
// ══════════════════════════════════════════════════════════════════════════════
+18
View File
@@ -66,6 +66,19 @@ const LESSON_LIST_COMPUTED = [
WHERE ul.lesson_id = "Lesson"."lesson_id"
)`,
},
{
key: "course_bound",
label: "Course Status",
type: "boolean",
literal: `(
SELECT EXISTS (
SELECT 1
FROM unit_lessons ul
JOIN course_units cu ON cu.unit_id = ul.unit_id
WHERE ul.lesson_id = "Lesson"."lesson_id"
)
)`,
},
];
// ══════════════════════════════════════════════════════════════════════════════
@@ -101,6 +114,11 @@ exports.getLessonsFlat = async (req, res) => {
WHERE ul.lesson_id = l.lesson_id) AS unit_count
FROM lessons l
WHERE l."deletedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM unit_lessons ul2
JOIN course_units cu ON cu.unit_id = ul2.unit_id
WHERE ul2.lesson_id = l.lesson_id
)
ORDER BY l.title ASC
`, { type: sequelize.QueryTypes.SELECT });
return R.success(res, "Lessons retrieved.", rows);
+2 -1
View File
@@ -24,6 +24,7 @@ async function list(req, res) {
order: [['createdAt', 'DESC']],
limit,
offset,
where: { show_in_notifications: true },
});
return R.success(res, 'Notifications fetched.', {
@@ -39,7 +40,7 @@ async function list(req, res) {
// ─── GET /admin/notifications/unseen ─────────────────────────────────────────
async function unseenCount(req, res) {
try {
const count = await AdminNotification.count({ where: { seen: false } });
const count = await AdminNotification.count({ where: { seen: false, show_in_notifications: true } });
return R.success(res, 'Unseen count fetched.', { count });
} catch (err) {
console.error('[NOTIFICATION] unseenCount error:', err);
@@ -32,6 +32,9 @@ async function applyBroadcastFields(broadcast, body) {
if (body.title !== undefined) broadcast.title = body.title;
if (body.message !== undefined) broadcast.message = body.message;
if (body.show_in_sticky !== undefined) broadcast.show_in_sticky = !!body.show_in_sticky;
if (body.show_in_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications;
if (body.target_type !== undefined) {
if (!ALLOWED_TARGET_TYPES.includes(body.target_type)) {
const err = new Error(`Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`);
@@ -101,10 +104,10 @@ exports.getBroadcasts = async (req, res) => {
if (Array.isArray(result?.data)) await attachTargetLabels(result.data);
return R.success(res, "Notification broadcasts retrieved.", result);
return R.success(res, "Announcements retrieved.", result);
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ALL]", err);
return R.error(res, "Could not retrieve notification broadcasts.", 500);
return R.error(res, "Could not retrieve announcements.", 500);
}
};
@@ -123,7 +126,7 @@ exports.getBroadcast = async (req, res) => {
],
});
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (!broadcast) return R.error(res, "Announcement not found.", 404);
const json = broadcast.toJSON();
@@ -142,7 +145,7 @@ exports.getBroadcast = async (req, res) => {
await attachTargetLabels(json);
return R.success(res, "Notification broadcast retrieved.", { data: json });
return R.success(res, "Announcement retrieved.", { data: json });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ONE]", err);
return R.error(res, "Internal server error.", 500);
@@ -153,7 +156,15 @@ exports.getBroadcast = async (req, res) => {
exports.createBroadcast = async (req, res) => {
try {
const { title, message, target_type, target_id, createdBy } = req.body;
const {
title,
message,
target_type,
target_id,
createdBy,
show_in_sticky,
show_in_notifications,
} = req.body;
if (!title) return R.error(res, "title is required.", 400);
if (!message) return R.error(res, "message is required.", 400);
@@ -162,20 +173,31 @@ exports.createBroadcast = async (req, res) => {
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, "target_id is required for this target_type.", 400);
if (!createdBy) return R.error(res, "createdBy is required.", 400);
const showSticky = show_in_sticky ?? false;
const showNotifs = show_in_notifications ?? true;
if (!showSticky && !showNotifs) {
return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400);
}
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
const t = await sequelize.transaction();
try {
const broadcast = await NotificationBroadcast.build({
title, message, createdBy, status: 'draft',
title,
message,
createdBy,
status: 'draft',
target_type,
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
show_in_sticky: showSticky,
show_in_notifications: showNotifs,
});
await broadcast.save({ transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'create_notification_broadcast', { entityType: 'notification_broadcast', entityId: broadcast.broadcast_id, details: { target_type } });
return R.success(res, "Notification broadcast created.", { data: broadcast }, 201);
return R.success(res, "Announcement created.", { data: broadcast }, 201);
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ }
throw dbErr;
@@ -202,12 +224,17 @@ exports.updateBroadcast = async (req, res) => {
const t = await sequelize.transaction();
try {
await applyBroadcastFields(broadcast, req.body);
if (!broadcast.show_in_sticky && !broadcast.show_in_notifications) {
const err = new Error("At least one of show_in_sticky or show_in_notifications must be enabled.");
err.status = 400;
throw err;
}
broadcast.updatedBy = req.body.updatedBy ?? null;
await broadcast.save({ transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
return R.success(res, "Notification broadcast updated.", { data: broadcast });
return R.success(res, "Announcement updated.", { data: broadcast });
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
throw dbErr;
@@ -227,7 +254,7 @@ exports.sendBroadcast = async (req, res) => {
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (!broadcast) return R.error(res, "Announcement not found.", 404);
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400);
const t = await sequelize.transaction();
@@ -237,6 +264,8 @@ exports.sendBroadcast = async (req, res) => {
const targetType = broadcast.target_type;
const targetId = broadcast.target_id;
const showInSticky = !!broadcast.show_in_sticky;
const showInNotifications = !!broadcast.show_in_notifications;
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
title: broadcast.title,
@@ -246,7 +275,10 @@ exports.sendBroadcast = async (req, res) => {
});
if (targetType === 'admin' || targetType === 'both') {
await AdminNotification.create({ ...baseNotify, seen: false }, { transaction: t });
await AdminNotification.create(
{ ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications },
{ transaction: t }
);
recipientCount += 1;
}
@@ -281,6 +313,8 @@ exports.sendBroadcast = async (req, res) => {
seen: false,
createdAt: now,
updatedAt: now,
show_in_sticky: showInSticky,
show_in_notifications: showInNotifications,
})),
{ validate: false, transaction: t }
);
@@ -294,7 +328,7 @@ exports.sendBroadcast = async (req, res) => {
await t.commit();
logActivity(req.user?.user_id, 'send_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId), details: { target_type: targetType, target_id: broadcast.target_id, recipient_count: recipientCount } });
return R.success(res, "Notification broadcast sent.", { data: broadcast });
return R.success(res, "Announcement sent.", { data: broadcast });
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
throw dbErr;
@@ -314,12 +348,12 @@ exports.archiveBroadcast = async (req, res) => {
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (!broadcast) return R.error(res, "Announcement not found.", 404);
await broadcast.update({ deletedBy: req.body.deletedBy ?? null });
await broadcast.destroy();
logActivity(req.user?.user_id, 'archive_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
return R.success(res, "Notification broadcast archived.");
return R.success(res, "Announcement archived.");
} catch (err) {
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
return R.error(res, "Internal server error.", 500);
@@ -359,13 +393,13 @@ exports.restoreBroadcast = async (req, res) => {
const { broadcastId } = req.params;
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Notification broadcast is not archived.", 400);
if (!broadcast) return R.error(res, "Announcement not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Announcement is not archived.", 400);
await broadcast.restore();
await broadcast.update({ deletedBy: null });
logActivity(req.user?.user_id, 'restore_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
return R.success(res, "Notification broadcast restored.", { data: broadcast });
return R.success(res, "Announcement restored.", { data: broadcast });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
return R.error(res, "Internal server error.", 500);
@@ -413,10 +447,10 @@ exports.getArchivedBroadcasts = async (req, res) => {
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
});
return R.success(res, "Archived notification broadcasts retrieved.", result);
return R.success(res, "Archived announcements retrieved.", result);
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", err);
return R.error(res, "Could not retrieve archived notification broadcasts.", 500);
return R.error(res, "Could not retrieve archived announcements.", 500);
}
};
@@ -427,15 +461,15 @@ exports.permanentlyDeleteBroadcast = async (req, res) => {
const { broadcastId } = req.params;
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Notification broadcast must be archived before it can be permanently deleted.", 400);
if (!broadcast) return R.error(res, "Announcement not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Announcement must be archived before it can be permanently deleted.", 400);
await broadcast.destroy({ force: true });
logActivity(req.user?.user_id, 'permanently_delete_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
return R.success(res, "Notification broadcast permanently deleted.");
return R.success(res, "Announcement permanently deleted.");
} catch (err) {
console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", err);
return R.error(res, "Could not permanently delete notification broadcast.", 500);
return R.error(res, "Could not permanently delete announcement.", 500);
}
};
@@ -447,22 +481,22 @@ exports.permanentlyDeleteBroadcasts = async (req, res) => {
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false });
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
if (!broadcasts.length) return R.error(res, "No announcements found.", 404);
const archived = broadcasts.filter((b) => b.deletedAt);
if (!archived.length) return R.error(res, "All selected notification broadcasts must be archived before they can be permanently deleted.", 400);
if (!archived.length) return R.error(res, "All selected announcements must be archived before they can be permanently deleted.", 400);
const archivedIds = archived.map((b) => b.broadcast_id);
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: archivedIds } }, force: true });
logActivity(req.user?.user_id, 'bulk_permanently_delete_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} notification broadcast(s) permanently deleted.`, {
return R.success(res, `${archivedIds.length} announcement(s) permanently deleted.`, {
deleted_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
});
} catch (err) {
console.error("[NOTIFICATION BROADCAST][BULK PERMANENT DELETE]", err);
return R.error(res, "Could not permanently delete notification broadcasts.", 500);
return R.error(res, "Could not permanently delete announcements.", 500);
}
};
@@ -11,10 +11,10 @@ exports.getNotificationTemplates = async (req, res) => {
const templates = await mdl_NotificationTemplate.findAll({
order: [['notify_type', 'ASC'], ['type', 'ASC']],
});
return R.success(res, 'Notification templates retrieved.', templates);
return R.success(res, 'Announcement templates retrieved.', templates);
} catch (err) {
console.error('[ADMIN][GET NOTIFICATION TEMPLATES]', err);
return R.error(res, 'Could not retrieve notification templates.', 500);
return R.error(res, 'Could not retrieve announcement templates.', 500);
}
};
@@ -23,11 +23,11 @@ exports.getNotificationTemplates = async (req, res) => {
exports.getNotificationTemplate = async (req, res) => {
try {
const template = await mdl_NotificationTemplate.findByPk(req.params.id);
if (!template) return R.error(res, 'Notification template not found.', 404);
return R.success(res, 'Notification template retrieved.', template);
if (!template) return R.error(res, 'Announcement template not found.', 404);
return R.success(res, 'Announcement template retrieved.', template);
} catch (err) {
console.error('[ADMIN][GET NOTIFICATION TEMPLATE]', err);
return R.error(res, 'Could not retrieve notification template.', 500);
return R.error(res, 'Could not retrieve announcement template.', 500);
}
};
@@ -39,7 +39,7 @@ exports.getNotificationTemplate = async (req, res) => {
exports.updateNotificationTemplate = async (req, res) => {
try {
const template = await mdl_NotificationTemplate.findByPk(req.params.id);
if (!template) return R.error(res, 'Notification template not found.', 404);
if (!template) return R.error(res, 'Announcement template not found.', 404);
const { label, title, message, publish } = req.body;
@@ -74,9 +74,9 @@ exports.updateNotificationTemplate = async (req, res) => {
logActivity(req.user?.user_id, 'update_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { type: template.type, published: isPublishing } });
return R.success(res, 'Notification template updated.', template);
return R.success(res, 'Announcement template updated.', template);
} catch (err) {
console.error('[ADMIN][UPDATE NOTIFICATION TEMPLATE]', err);
return R.error(res, 'Could not update notification template.', 500);
return R.error(res, 'Could not update announcement template.', 500);
}
};
+66 -4
View File
@@ -26,6 +26,7 @@ const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
const { TaskCompletion } = require('../../models/task/task_completion.mdl');
const logActivity = require('../../utils/logActivity.util');
const { nextOrderIndex, reorderJunction } = require('../../utils/courses/hierarchy.util');
// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ──
const normalizeUrl = (url) => {
@@ -36,7 +37,7 @@ const normalizeUrl = (url) => {
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
const TASK_FIELDS = ['name', 'description', 'deadline', 'status', 'createdAt', 'updatedAt', 'deletedAt'];
const TASK_FIELDS = ['name', 'description', 'deadline', 'order_index', 'is_required', 'status', 'createdAt', 'updatedAt', 'deletedAt'];
const FILTERABLE_MODELS = {
TaskList: TaskList,
Task: Task,
@@ -420,6 +421,38 @@ exports.assignGroups = async (req, res) => {
await t.commit();
logActivity(req.user.user_id, 'assign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: newIds } });
// ── Notify every member of the newly-assigned group(s) ─────────────────
if (newIds.length) {
try {
const taskCount = await Task.count({ where: { task_list_id: taskListId } });
const memberRows = await mdl_UserGroupMembers.findAll({
where: { group_id: newIds },
attributes: ['user_id'],
});
const seenUsers = new Set();
const userIds = memberRows.filter(({ user_id }) => {
if (seenUsers.has(user_id)) return false;
seenUsers.add(user_id);
return true;
}).map((m) => m.user_id);
if (userIds.length) {
const now = new Date();
const notify = await renderNotification({ type: 'task_assigned', data: {
taskListName: taskList.name,
taskCount,
} });
await UserNotification.bulkCreate(
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
{ validate: false }
);
}
} catch (notifyErr) {
console.error('[ADMIN][ASSIGN GROUPS][NOTIFY]', notifyErr);
}
}
return R.success(res, `${newIds.length} group(s) assigned.`, {
assigned_ids: newIds,
already_assigned_ids: existingIds,
@@ -556,7 +589,7 @@ exports.createTask = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId } = req.params;
const { name, description, deadline, requirements = [] } = req.body;
const { name, description, deadline, is_required, requirements = [] } = req.body;
if (!name) return R.error(res, 'Task name is required.', 400);
@@ -566,12 +599,16 @@ exports.createTask = async (req, res) => {
return R.error(res, 'Task list not found.', 404);
}
const order_index = await nextOrderIndex(Task, { task_list_id: taskListId }, t);
const task = await Task.create(
{
task_list_id: taskListId,
name,
description,
deadline: deadline || null,
order_index,
is_required: is_required ?? true,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
},
@@ -630,10 +667,10 @@ exports.updateTask = async (req, res) => {
return R.error(res, 'Task not found.', 404);
}
const { name, description, deadline, status, requirements } = req.body;
const { name, description, deadline, status, is_required, requirements } = req.body;
await task.update(
{ name, description, deadline: deadline || null, status, updatedBy: req.user.user_id },
{ name, description, deadline: deadline || null, status, is_required, updatedBy: req.user.user_id },
{ transaction: t }
);
@@ -757,6 +794,31 @@ exports.updateTask = async (req, res) => {
}
};
// ─── REORDER ──────────────────────────────────────────────────────────────────
// PATCH /admin/task-lists/:taskListId/tasks/order { task_ids: [orderedIds] }
exports.reorderTasks = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId } = req.params;
const { task_ids = [] } = req.body;
if (!task_ids.length) { await t.rollback(); return R.error(res, 'task_ids is required.', 400); }
const taskList = await TaskList.findByPk(taskListId, { transaction: t });
if (!taskList) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
await reorderJunction(Task, 'task_list_id', taskListId, 'task_id', task_ids, t);
await t.commit();
logActivity(req.user.user_id, 'reorder_tasks', { entityType: 'task_list', entityId: taskListId, details: { task_ids } });
return R.success(res, 'Task order updated.');
} catch (err) {
await t.rollback();
console.error('[ADMIN][REORDER TASKS]', err);
return R.error(res, 'Could not reorder tasks.', 500);
}
};
// ─── GET ARCHIVED TASK LISTS ──────────────────────────────────────────────────
exports.getArchivedTaskLists = async (req, res) => {
@@ -14,6 +14,9 @@ const sequelize = require('../../config/db.config');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
const { Task } = require('../../models/task/task.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { checkTaskCompletion, fireTaskCompletedEvent } = require('../client/task.controller');
const { adminExclude } = require('../../models/task/task_completion.attributes');
const R = require('../../utils/response.util');
@@ -140,6 +143,69 @@ exports.getCompletionsByUser = async (req, res) => {
}
};
// ─── REVIEW ───────────────────────────────────────────────────────────────────
// PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/review
// Approves/rejects a submission for a requirement flagged requires_review.
// Notifies the submitting learner via the existing template pattern (same shape
// as updateTask's task_requirements_updated notify block).
exports.reviewSubmission = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId, completionId } = req.params;
const { status, review_note } = req.body;
if (!['approved', 'rejected'].includes(status)) {
await t.rollback();
return R.error(res, 'status must be "approved" or "rejected".', 400);
}
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
const completion = await TaskCompletion.findOne({
where: { completion_id: completionId, task_id: taskId },
transaction: t,
});
if (!completion) { await t.rollback(); return R.error(res, 'Completion not found.', 404); }
const wasComplete = status === 'approved' ? await checkTaskCompletion(completion.user_id, taskId) : false;
await completion.update({
status,
review_note: review_note || null,
reviewed_by: req.user.user_id,
reviewed_at: new Date(),
updatedBy: req.user.user_id,
}, { transaction: t });
await t.commit();
if (status === 'approved' && !wasComplete && await checkTaskCompletion(completion.user_id, taskId)) {
fireTaskCompletedEvent(completion.user_id, taskId); // fire-and-forget
}
logActivity(req.user.user_id, 'review_task_submission', {
entityType: 'task_completion', entityId: completionId, details: { task_id: taskId, status },
});
try {
const notify = await renderNotification({ type: 'task_submission_reviewed', data: {
taskName: task.name, status, review_note: review_note || null,
} });
await UserNotification.create({ user_id: completion.user_id, ...notify, seen: false });
} catch (notifyErr) {
console.error('[ADMIN][REVIEW SUBMISSION][NOTIFY]', notifyErr);
}
return R.success(res, 'Submission reviewed.', completion);
} catch (err) {
await t.rollback();
console.error('[ADMIN][REVIEW SUBMISSION]', err);
return R.error(res, 'Could not review submission.', 500);
}
};
// ─── ARCHIVE ──────────────────────────────────────────────────────────────────
// DELETE /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId
+3 -3
View File
@@ -104,7 +104,7 @@ function computeDurationDays(value, unit) {
exports.createPlan = async (req, res) => {
try {
const { tier_category_id, label, description, duration_value, duration_unit = 'day', price, currency } = req.body;
const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency } = req.body;
if (!tier_category_id || !label || !duration_value || !price)
return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400);
@@ -120,7 +120,7 @@ exports.createPlan = async (req, res) => {
const plan = await mdl_TierPlans.create({
tier_category_id: category.tier_category_id,
tier: category.slug,
label, description, duration_days, duration_unit, price, currency,
label, description, features, duration_days, duration_unit, price, currency,
});
const plain = plan.get({ plain: true });
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
@@ -136,7 +136,7 @@ exports.updatePlan = async (req, res) => {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
const allowed = ['label', 'description', 'price', 'currency', 'is_active', 'tier_category_id'];
const allowed = ['label', 'description', 'features', 'price', 'currency', 'is_active', 'tier_category_id'];
const updates = {};
for (const k of allowed) {
if (req.body[k] !== undefined) updates[k] = req.body[k];
+6 -4
View File
@@ -153,11 +153,12 @@ exports.getUnit = async (req, res) => {
exports.createUnit = async (req, res) => {
const t = await sequelize.transaction();
try {
const { title, description, course_id, order, createdBy } = req.body;
const { title, description, subscription, course_id, order, createdBy } = req.body;
if (!title) return R.error(res, "Title is required.", 400);
const unit = await Unit.create({
title,
subscription: subscription || null,
description: description ?? null,
duration_seconds: 0,
createdBy: createdBy ?? req.user?.user_id ?? null,
@@ -196,10 +197,11 @@ exports.updateUnit = async (req, res) => {
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const { title, description, updatedBy } = req.body;
const { title, description, subscription, updatedBy } = req.body;
if (title !== undefined) unit.title = title;
if (description !== undefined) unit.description = description;
if (title !== undefined) unit.title = title;
if (description !== undefined) unit.description = description;
if (subscription !== undefined) unit.subscription = subscription || null;
unit.updatedBy = updatedBy ?? req.user?.user_id ?? null;
await unit.save();