token issues more for pdf preview missing

With DEV to QAS live test environment

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-05 20:43:16 +08:00
parent 0c7f5ccd0f
commit 141b7ab592
5 changed files with 170 additions and 26 deletions
+31 -1
View File
@@ -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;
+98 -2
View File
@@ -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) => {
+11 -2
View File
@@ -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.
// <img>/<video> tags were unaffected since opaque loads skip CORS checks.
const allowedOrigins = (process.env.ALLOWED_ORIGINS || process.env.APP_URL || "*").split(",");
const requestOrigin = req.headers.origin;
if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
res.setHeader("Access-Control-Allow-Origin", requestOrigin);
}
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Range, Authorization");
+1
View File
@@ -24,6 +24,7 @@ router.get("/by-subscription", ctrl.getCoursesBySubscription);
router.get("/units-flat", ctrl.getUnitsFlat);
router.get("/lessons-flat", ctrl.getLessonsFlat);
router.get("/quizzes-flat", ctrl.getQuizzesFlat);
router.get("/uuid/:uuid/structure-counts", ctrl.getCourseStructureCounts);
router.put("/order", ctrl.reorderCourses); // persist top-level course catalog ordering { course_ids }
// ── then :courseId ────────────────────────────────────────────────────────────
+29 -21
View File
@@ -3,55 +3,63 @@ const controller = require("../../controllers/admin/task.controller");
const completionController = require("../../controllers/admin/task_completion.controller");
const { sensitiveOpsLimiter } = require("../../middleware/rateLimiter.middleware");
// NOTE on rate limiting: every route below already sits behind adminLimiter
// (200 req/15min per admin user — applied blanket-wide in admin.routes.js).
// sensitiveOpsLimiter (10 req/hour, ALSO keyed per admin user and shared
// across every route it's attached to) is reserved for genuinely irreversible
// actions — permanent delete — so routine CRUD doesn't compete with those for
// the same tight budget.
// ─── Task Lists ───────────────────────────────────────────────────────────────
router.get("/", controller.getTaskLists);
router.get("/archived", controller.getArchivedTaskLists);
router.get("/field-values", controller.getTaskListFieldValues);
router.post("/", sensitiveOpsLimiter, controller.createTaskList);
router.post("/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTaskLists);
router.post("/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTaskLists);
router.post("/", controller.createTaskList);
router.post("/bulk-archive", controller.bulkArchiveTaskLists);
router.post("/bulk-restore", controller.bulkRestoreTaskLists);
router.post("/bulk-delete", sensitiveOpsLimiter, controller.bulkPermanentlyDeleteTaskLists);
// ─── Task List (single) ───────────────────────────────────────────────────────
router.get("/:taskListId", controller.getTaskList);
router.get("/:taskListId/permanent-delete-impact", controller.getTaskListPermanentDeleteImpact);
router.patch("/:taskListId", sensitiveOpsLimiter, controller.updateTaskList);
router.delete("/:taskListId", sensitiveOpsLimiter, controller.archiveTaskList);
router.patch("/:taskListId", controller.updateTaskList);
router.delete("/:taskListId", controller.archiveTaskList);
router.delete("/:taskListId/permanent", sensitiveOpsLimiter, controller.permanentlyDeleteTaskList);
router.patch("/:taskListId/restore", sensitiveOpsLimiter, controller.restoreTaskList);
router.patch("/:taskListId/restore", controller.restoreTaskList);
// ─── Task List Groups ─────────────────────────────────────────────────────────
router.get("/:taskListId/groups", controller.getTaskListGroups);
router.put("/:taskListId/groups", sensitiveOpsLimiter, controller.syncGroups);
router.post("/:taskListId/groups/assign", sensitiveOpsLimiter, controller.assignGroups);
router.post("/:taskListId/groups/unassign", sensitiveOpsLimiter, controller.unassignGroups);
router.put("/:taskListId/groups", controller.syncGroups);
router.post("/:taskListId/groups/assign", controller.assignGroups);
router.post("/:taskListId/groups/unassign", controller.unassignGroups);
// ─── Tasks (nested under task-list) ──────────────────────────────────────────
router.get("/:taskListId/tasks", controller.getTasks);
router.get("/:taskListId/tasks/archived", controller.getArchivedTasks);
router.get("/:taskListId/tasks/field-values", controller.getTaskFieldValues);
router.get("/:taskListId/tasks/requirement-completion-check", controller.checkRequirementCompletion);
router.post("/:taskListId/tasks", sensitiveOpsLimiter, controller.createTask);
router.post("/:taskListId/tasks/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTasks);
router.post("/:taskListId/tasks/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTasks);
router.post("/:taskListId/tasks", controller.createTask);
router.post("/:taskListId/tasks/bulk", controller.createTasksBulk);
router.post("/:taskListId/tasks/bulk-archive", controller.bulkArchiveTasks);
router.post("/:taskListId/tasks/bulk-restore", controller.bulkRestoreTasks);
router.post("/:taskListId/tasks/bulk-delete", sensitiveOpsLimiter, controller.bulkPermanentlyDeleteTasks);
router.patch("/:taskListId/tasks/order", sensitiveOpsLimiter, controller.reorderTasks);
router.patch("/:taskListId/tasks/order", controller.reorderTasks);
// ─── Tasks (single) ───────────────────────────────────────────────────────────
router.get("/:taskListId/tasks/:taskId", controller.getTask);
router.patch("/:taskListId/tasks/:taskId", sensitiveOpsLimiter, controller.updateTask);
router.delete("/:taskListId/tasks/:taskId", sensitiveOpsLimiter, controller.archiveTask);
router.patch("/:taskListId/tasks/:taskId", controller.updateTask);
router.delete("/:taskListId/tasks/:taskId", controller.archiveTask);
router.delete("/:taskListId/tasks/:taskId/permanent", sensitiveOpsLimiter, controller.permanentlyDeleteTask);
router.patch("/:taskListId/tasks/:taskId/restore", sensitiveOpsLimiter, controller.restoreTask);
router.patch("/:taskListId/tasks/:taskId/restore", controller.restoreTask);
// ─── Completions (nested under task) ─────────────────────────────────────────
router.get("/:taskListId/tasks/:taskId/completions", completionController.getCompletions);
router.get("/:taskListId/tasks/:taskId/completions/user/:userId", completionController.getCompletionsByUser);
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-archive", completionController.bulkArchiveCompletions);
router.post("/:taskListId/tasks/:taskId/completions/bulk-restore", completionController.bulkRestoreCompletions);
router.get("/:taskListId/tasks/:taskId/completions/:completionId", completionController.getCompletion);
router.patch("/:taskListId/tasks/:taskId/completions/:completionId/review", sensitiveOpsLimiter, completionController.reviewSubmission);
router.delete("/:taskListId/tasks/:taskId/completions/:completionId", sensitiveOpsLimiter, completionController.archiveCompletion);
router.patch("/:taskListId/tasks/:taskId/completions/:completionId/restore", sensitiveOpsLimiter, completionController.restoreCompletion);
router.patch("/:taskListId/tasks/:taskId/completions/:completionId/review", completionController.reviewSubmission);
router.delete("/:taskListId/tasks/:taskId/completions/:completionId", completionController.archiveCompletion);
router.patch("/:taskListId/tasks/:taskId/completions/:completionId/restore", completionController.restoreCompletion);
module.exports = router;