added xlsx js for excel

This commit is contained in:
2026-07-20 22:06:10 +08:00
parent c85641371e
commit 2c12b5a6c3
4 changed files with 219 additions and 6 deletions
+184 -2
View File
@@ -28,6 +28,13 @@ const { TaskCompletion } = require('../../models/task/task_completion.mdl');
const logActivity = require('../../utils/logActivity.util');
const { nextOrderIndex, reorderJunction } = require('../../utils/courses/hierarchy.util');
const { wouldCreateCycle } = require('../../utils/courses/taskPrerequisites.util');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const LessonReadingProgress = require('../../models/courses/lesson_reading_progress.mdl');
const UnitReadingProgress = require('../../models/courses/unit_reading_progress.mdl');
const Lesson = require('../../models/courses/lessons.mdl');
const Unit = require('../../models/courses/units.mdl');
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
const UnitQuiz = require('../../models/courses/unit_quiz.mdl');
// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ──
const normalizeUrl = (url) => {
@@ -671,6 +678,142 @@ const PREREQUISITE_INCLUDE = {
class TaskValidationError extends Error {}
// ─── Pre-completed assignees check ────────────────────────────────────────────
// A read_course/read_unit/read_lesson/pass_quiz requirement can reference content
// that a task list's assignees already finished BEFORE this requirement existed.
// That's not an error — task_reading_progress_sync.service.js's hydrateReadTaskProgress
// (client-side, on task list load) already auto-marks it done for them — but the
// admin creating/editing the task has no visibility into it otherwise. This is a
// heads-up, not a validator: it never blocks create/update, only informs.
const READ_TYPE_TO_PROGRESS_TYPE = { read_course: 'course', read_unit: 'unit', read_lesson: 'lesson' };
const progressKeyType = (reqType) => (reqType === 'pass_quiz' ? 'pass_quiz' : READ_TYPE_TO_PROGRESS_TYPE[reqType]);
async function getPreCompletedAssignees(taskListId, requirements, transaction) {
const contentReqs = (requirements ?? []).filter(
(r) => r.reference_id && progressKeyType(r.type)
);
if (!contentReqs.length) return [];
const members = await getTaskListMembers(taskListId);
if (!members.length) return [];
const userIds = members.map((m) => m.user_id);
const completedByKey = new Map(); // `${progressType}:${reference_id}` -> Set(user_id)
const markCompleted = (progressType, referenceId, userId) => {
const key = `${progressType}:${referenceId}`;
if (!completedByKey.has(key)) completedByKey.set(key, new Set());
completedByKey.get(key).add(userId);
};
// ── read_course / read_unit / read_lesson ───────────────────────────────
const readReqs = contentReqs.filter((r) => READ_TYPE_TO_PROGRESS_TYPE[r.type]);
if (readReqs.length) {
const refsByProgressType = readReqs.reduce((acc, r) => {
const progressType = READ_TYPE_TO_PROGRESS_TYPE[r.type];
if (!acc[progressType]) acc[progressType] = new Set();
acc[progressType].add(r.reference_id);
return acc;
}, {});
const courseRows = await CourseReadingProgress.findAll({
where: {
user_id: { [Op.in]: userIds },
status: 'completed',
[Op.or]: Object.entries(refsByProgressType).map(([type, refs]) => ({
type, reference_id: { [Op.in]: [...refs] },
})),
},
attributes: ['user_id', 'type', 'reference_id'],
transaction,
});
for (const row of courseRows) markCompleted(row.type, row.reference_id, row.user_id);
// Standalone lesson/unit (no parent course) — resolve uuid -> numeric PK first.
const lessonUuids = [...(refsByProgressType.lesson ?? [])];
if (lessonUuids.length) {
const lessons = await Lesson.findAll({
where: { uuid: { [Op.in]: lessonUuids } },
attributes: ['lesson_id', 'uuid'],
transaction,
});
if (lessons.length) {
const uuidByLessonId = new Map(lessons.map((l) => [l.lesson_id, l.uuid]));
const rows = await LessonReadingProgress.findAll({
where: { user_id: { [Op.in]: userIds }, lesson_id: { [Op.in]: [...uuidByLessonId.keys()] }, status: 'completed' },
attributes: ['user_id', 'lesson_id'],
transaction,
});
for (const row of rows) {
const uuid = uuidByLessonId.get(row.lesson_id);
if (uuid) markCompleted('lesson', uuid, row.user_id);
}
}
}
const unitUuids = [...(refsByProgressType.unit ?? [])];
if (unitUuids.length) {
const units = await Unit.findAll({
where: { uuid: { [Op.in]: unitUuids } },
attributes: ['unit_id', 'uuid'],
transaction,
});
if (units.length) {
const uuidByUnitId = new Map(units.map((u) => [u.unit_id, u.uuid]));
const rows = await UnitReadingProgress.findAll({
where: { user_id: { [Op.in]: userIds }, unit_id: { [Op.in]: [...uuidByUnitId.keys()] }, status: 'completed' },
attributes: ['user_id', 'unit_id'],
transaction,
});
for (const row of rows) {
const uuid = uuidByUnitId.get(row.unit_id);
if (uuid) markCompleted('unit', uuid, row.user_id);
}
}
}
}
// ── pass_quiz ────────────────────────────────────────────────────────────
const quizReqs = contentReqs.filter((r) => r.type === 'pass_quiz');
if (quizReqs.length) {
const quizUuids = [...new Set(quizReqs.map((r) => r.reference_id))];
const quizzes = await UnitQuiz.findAll({
where: { uuid: { [Op.in]: quizUuids } },
attributes: ['quiz_id', 'uuid'],
transaction,
});
const uuidByQuizId = new Map(quizzes.map((q) => [q.quiz_id, q.uuid]));
const quizIds = quizzes.map((q) => q.quiz_id);
if (quizIds.length) {
const rows = await QuizAttempt.findAll({
where: { quiz_id: { [Op.in]: quizIds }, user_id: { [Op.in]: userIds }, passed: true },
attributes: ['user_id', 'quiz_id'],
transaction,
});
for (const row of rows) {
const uuid = uuidByQuizId.get(row.quiz_id);
if (uuid) markCompleted('pass_quiz', uuid, row.user_id);
}
}
}
// ── One entry per requirement that has at least one already-completed assignee ──
const results = [];
for (const r of contentReqs) {
const key = `${progressKeyType(r.type)}:${r.reference_id}`;
const completedCount = completedByKey.get(key)?.size ?? 0;
if (completedCount > 0) {
results.push({
type: r.type,
reference_id: r.reference_id,
reference_label: r.reference_label ?? null,
completedCount,
totalAssignees: userIds.length,
});
}
}
return results;
}
// ─── Active-dependent lookup — guards archive/permanent-delete ────────────────
// paranoid: false on the base query so this still works when checking an
// already-archived task (permanent-delete path) — the nested 'dependents'
@@ -773,6 +916,31 @@ exports.getTasks = async (req, res) => {
}
};
// ─── REQUIREMENT COMPLETION CHECK ────────────────────────────────────────────
// GET /:taskListId/tasks/requirement-completion-check?type=&reference_id=&reference_label=
//
// Live check used by RequirementBuilder.jsx the moment a content item is picked —
// reports whether any of this task list's assignees already completed it. Purely
// informational (see getPreCompletedAssignees above); returns null when nobody has.
exports.checkRequirementCompletion = async (req, res) => {
try {
const { taskListId } = req.params;
const { type, reference_id, reference_label } = req.query;
if (!type || !reference_id) return R.error(res, 'type and reference_id are required.', 400);
const [result] = await getPreCompletedAssignees(
taskListId,
[{ type, reference_id, reference_label }],
);
return R.success(res, 'Requirement completion check complete.', result ?? null);
} catch (err) {
console.error('[ADMIN][CHECK REQUIREMENT COMPLETION]', err);
return R.error(res, 'Could not check requirement completion.', 500);
}
};
// ─── GET ONE ──────────────────────────────────────────────────────────────────
exports.getTask = async (req, res) => {
@@ -872,8 +1040,14 @@ exports.createTask = async (req, res) => {
}, PREREQUISITE_INCLUDE],
});
// Heads-up only — never blocks creation. See getPreCompletedAssignees.
const warnings = await getPreCompletedAssignees(
taskListId,
(full.requirements ?? []).map((r) => ({ type: r.type, reference_id: r.reference_id, reference_label: r.reference_label })),
);
logActivity(req.user.user_id, 'create_task', { entityType: 'task', entityId: task.task_id, details: { name: task.name } });
return R.success(res, 'Task created successfully.', full, 201);
return R.success(res, 'Task created successfully.', { ...full.toJSON(), warnings }, 201);
} catch (err) {
await t.rollback();
if (err instanceof TaskValidationError) return R.error(res, err.message, 400);
@@ -977,6 +1151,14 @@ exports.updateTask = async (req, res) => {
logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) });
// Heads-up only — never blocks the update. See getPreCompletedAssignees.
const warnings = Array.isArray(requirements)
? await getPreCompletedAssignees(
taskListId,
(full.requirements ?? []).map((r) => ({ type: r.type, reference_id: r.reference_id, reference_label: r.reference_label })),
)
: [];
// ── Notify assigned users when requirements changed ────────────────────
if (Array.isArray(requirements)) {
try {
@@ -1040,7 +1222,7 @@ exports.updateTask = async (req, res) => {
}
}
return R.success(res, 'Task updated successfully.', full);
return R.success(res, 'Task updated successfully.', { ...full.toJSON(), warnings });
} catch (err) {
await t.rollback();
if (err instanceof TaskValidationError) return R.error(res, err.message, 400);