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. + // /