diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js
index ef431f7..770d513 100644
--- a/controllers/admin/courses.controller.js
+++ b/controllers/admin/courses.controller.js
@@ -12,7 +12,7 @@ const { syncJunction } = require("../../utils/courses/junction.util");
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
-const { flattenUnits, flattenLessons, nextOrderIndex, reorderJunction } = require("../../utils/courses/hierarchy.util");
+const { flattenUnits, flattenLessons, nextOrderIndex, reorderJunction, getCourseUnitIds, countCourseLessons } = require("../../utils/courses/hierarchy.util");
const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util');
const UserNotification = require('../../models/notifications/user_notification.mdl');
@@ -2393,6 +2393,36 @@ exports.getCoursesFlat = async (req, res) => {
}
};
+// One course's structure counts, looked up by uuid — used by the Task
+// requirement viewer (ViewTask.jsx) to show how many units/lessons/quizzes a
+// "Read a Course" requirement actually points to, without pulling the full
+// nested course tree.
+exports.getCourseStructureCounts = async (req, res) => {
+ try {
+ const { uuid } = req.params;
+
+ const course = await Course.findOne({ where: { uuid, ...notDeleted }, attributes: ["course_id"] });
+ if (!course) return R.error(res, "Course not found.", 404);
+
+ const unitIds = await getCourseUnitIds(course.course_id);
+ const [lessonCount, quizCount] = await Promise.all([
+ countCourseLessons(course.course_id),
+ unitIds.length
+ ? UnitQuiz.count({ where: { unit_id: { [Op.in]: unitIds } } })
+ : 0,
+ ]);
+
+ return R.success(res, "Course structure counts retrieved.", {
+ unitCount: unitIds.length,
+ lessonCount,
+ quizCount,
+ });
+ } catch (err) {
+ console.error("[COURSE][STRUCTURE COUNTS]", err);
+ return R.error(res, "Could not retrieve course structure counts.", 500);
+ }
+};
+
exports.getCoursesBySubscription = async (req, res) => {
try {
const { slug } = req.query;
diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js
index f77a2bd..aa59be2 100644
--- a/controllers/admin/task.controller.js
+++ b/controllers/admin/task.controller.js
@@ -558,8 +558,8 @@ exports.unassignGroups = async (req, res) => {
// Body: { group_ids: [1, 2, 3] }
//
// Replaces the full assigned-group set in one request/one transaction, so an
-// edit that both adds and removes groups only costs a single sensitiveOpsLimiter
-// hit instead of two (assign + unassign).
+// edit that both adds and removes groups is a single atomic operation instead
+// of two separate calls (assign + unassign).
exports.syncGroups = async (req, res) => {
const t = await sequelize.transaction();
@@ -1056,6 +1056,102 @@ exports.createTask = async (req, res) => {
}
};
+// ─── BULK CREATE ──────────────────────────────────────────────────────────────
+// POST /:taskListId/tasks/bulk
+// Body: { tasks: [{ name, description, deadline, requirements }, ...] }
+//
+// Batches Task + TaskRequirement creation into one transaction/request — used
+// by the Create Task List wizard so queuing N tasks costs one request instead
+// of N sequential POST .../tasks calls (see CreateTaskList.jsx handleCreate).
+// Prerequisite wiring is intentionally not supported here: queued tasks can't
+// reference a not-yet-created sibling task's id, same as createTask today
+// when called from this flow.
+exports.createTasksBulk = async (req, res) => {
+ const t = await sequelize.transaction();
+ try {
+ const { taskListId } = req.params;
+ const { tasks } = req.body;
+
+ if (!Array.isArray(tasks) || !tasks.length)
+ return R.error(res, 'No tasks provided.', 400);
+ if (tasks.some((task) => !task.name)) {
+ return R.error(res, 'Every task requires a name.', 400);
+ }
+
+ const taskList = await TaskList.findByPk(taskListId, { transaction: t });
+ if (!taskList) {
+ await t.rollback();
+ return R.error(res, 'Task list not found.', 404);
+ }
+
+ const startOrderIndex = await nextOrderIndex(Task, { task_list_id: taskListId }, t);
+
+ const createdTasks = await Task.bulkCreate(
+ tasks.map((task, i) => ({
+ task_list_id: taskListId,
+ name: task.name,
+ description: task.description,
+ deadline: task.deadline || null,
+ order_index: startOrderIndex + i,
+ createdBy: req.user.user_id,
+ updatedBy: req.user.user_id,
+ })),
+ { transaction: t }
+ );
+
+ const reqRows = createdTasks.flatMap((task, i) =>
+ (tasks[i].requirements ?? []).map((r, j) => ({
+ ...r,
+ task_id: task.task_id,
+ order: r.order ?? j,
+ reference_id: r.reference_id || null, // '' → null (UUID column)
+ reference_label: r.reference_label || null, // '' → null
+ link_url: normalizeUrl(r.link_url),
+ link_label: r.link_label || null,
+ createdBy: req.user.user_id,
+ updatedBy: req.user.user_id,
+ }))
+ );
+ if (reqRows.length) await TaskRequirement.bulkCreate(reqRows, { transaction: t });
+
+ await t.commit();
+
+ const taskIds = createdTasks.map((task) => task.task_id);
+ const full = await Task.findAll({
+ where: { task_id: { [Op.in]: taskIds } },
+ attributes: { exclude: adminExclude },
+ include: [{
+ model: TaskRequirement,
+ as: 'requirements',
+ attributes: { exclude: adminExclude },
+ order: [['order', 'ASC']],
+ }, PREREQUISITE_INCLUDE],
+ order: [['order_index', 'ASC']],
+ });
+
+ // Heads-up only — never blocks creation. See getPreCompletedAssignees.
+ const allRequirements = full.flatMap((task) =>
+ (task.requirements ?? []).map((r) => ({ type: r.type, reference_id: r.reference_id, reference_label: r.reference_label }))
+ );
+ const warnings = await getPreCompletedAssignees(taskListId, allRequirements);
+
+ logActivity(req.user.user_id, 'bulk_create_tasks', {
+ entityType: 'task_list',
+ entityId: Number(taskListId),
+ details: { count: createdTasks.length, task_ids: taskIds },
+ });
+
+ return R.success(res, `${createdTasks.length} task(s) created successfully.`, {
+ tasks: full.map((task) => task.toJSON()),
+ warnings,
+ }, 201);
+ } catch (err) {
+ await t.rollback();
+ console.error('[ADMIN][BULK CREATE TASKS]', err);
+ return R.error(res, 'Could not create tasks.', 500);
+ }
+};
+
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateTask = async (req, res) => {
diff --git a/controllers/client/media.controller.js b/controllers/client/media.controller.js
index d6321c8..c1fe512 100644
--- a/controllers/client/media.controller.js
+++ b/controllers/client/media.controller.js
@@ -218,8 +218,17 @@ exports.streamAsset = async (req, res) => {
const { token } = req.params;
// ── CORS ──────────────────────────────────────────────────────────────────
- const allowedOrigin = process.env.FRONTEND_URL ?? "http://localhost:5173";
- res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
+ // Mirrors server.js's global cors() origin check (reflect against
+ // ALLOWED_ORIGINS) instead of a single hardcoded FRONTEND_URL — a static
+ // origin here silently overwrote the correct header the global middleware
+ // already set, breaking any CORS-checked read (e.g. pdf.js's Range-header
+ // fetch) whenever FRONTEND_URL drifted from the deployed frontend domain.
+ //
/