mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added xlsx js for excel
This commit is contained in:
@@ -28,6 +28,13 @@ const { TaskCompletion } = require('../../models/task/task_completion.mdl');
|
|||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const { nextOrderIndex, reorderJunction } = require('../../utils/courses/hierarchy.util');
|
const { nextOrderIndex, reorderJunction } = require('../../utils/courses/hierarchy.util');
|
||||||
const { wouldCreateCycle } = require('../../utils/courses/taskPrerequisites.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 ──
|
// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ──
|
||||||
const normalizeUrl = (url) => {
|
const normalizeUrl = (url) => {
|
||||||
@@ -671,6 +678,142 @@ const PREREQUISITE_INCLUDE = {
|
|||||||
|
|
||||||
class TaskValidationError extends Error {}
|
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 ────────────────
|
// ─── Active-dependent lookup — guards archive/permanent-delete ────────────────
|
||||||
// paranoid: false on the base query so this still works when checking an
|
// paranoid: false on the base query so this still works when checking an
|
||||||
// already-archived task (permanent-delete path) — the nested 'dependents'
|
// 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 ──────────────────────────────────────────────────────────────────
|
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getTask = async (req, res) => {
|
exports.getTask = async (req, res) => {
|
||||||
@@ -872,8 +1040,14 @@ exports.createTask = async (req, res) => {
|
|||||||
}, PREREQUISITE_INCLUDE],
|
}, 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 } });
|
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) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
if (err instanceof TaskValidationError) return R.error(res, err.message, 400);
|
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) });
|
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 ────────────────────
|
// ── Notify assigned users when requirements changed ────────────────────
|
||||||
if (Array.isArray(requirements)) {
|
if (Array.isArray(requirements)) {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
if (err instanceof TaskValidationError) return R.error(res, err.message, 400);
|
if (err instanceof TaskValidationError) return R.error(res, err.message, 400);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ router.post("/:taskListId/groups/unassign", sensitiveOpsLimiter, controller.unas
|
|||||||
router.get("/:taskListId/tasks", controller.getTasks);
|
router.get("/:taskListId/tasks", controller.getTasks);
|
||||||
router.get("/:taskListId/tasks/archived", controller.getArchivedTasks);
|
router.get("/:taskListId/tasks/archived", controller.getArchivedTasks);
|
||||||
router.get("/:taskListId/tasks/field-values", controller.getTaskFieldValues);
|
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", sensitiveOpsLimiter, controller.createTask);
|
||||||
router.post("/:taskListId/tasks/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTasks);
|
router.post("/:taskListId/tasks/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTasks);
|
||||||
router.post("/:taskListId/tasks/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTasks);
|
router.post("/:taskListId/tasks/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTasks);
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ const AUDIT_ID_FIELDS = new Set(["createdBy", "updatedBy", "deletedBy"]);
|
|||||||
* "column reference is ambiguous" as soon as a query joins another table
|
* "column reference is ambiguous" as soon as a query joins another table
|
||||||
* that happens to share a column name (e.g. Users + UserGroups both have
|
* that happens to share a column name (e.g. Users + UserGroups both have
|
||||||
* is_active/createdAt/updatedAt/deletedAt).
|
* is_active/createdAt/updatedAt/deletedAt).
|
||||||
|
* @param {Set<string>} [dateFields] - fields whose picklist values are
|
||||||
|
* calendar days (see below) — matched by range, not substring.
|
||||||
* @returns {Object} Sequelize where clause
|
* @returns {Object} Sequelize where clause
|
||||||
*/
|
*/
|
||||||
function buildWhere(filters = [], allowedFields = new Set(), parentAlias = null) {
|
function buildWhere(filters = [], allowedFields = new Set(), parentAlias = null, dateFields = new Set()) {
|
||||||
const where = [];
|
const where = [];
|
||||||
|
|
||||||
for (const { id, value } of filters) {
|
for (const { id, value } of filters) {
|
||||||
@@ -42,6 +44,31 @@ function buildWhere(filters = [], allowedFields = new Set(), parentAlias = null)
|
|||||||
|
|
||||||
const qualifiedCol = parentAlias ? `${parentAlias}.${id}` : id;
|
const qualifiedCol = parentAlias ? `${parentAlias}.${id}` : id;
|
||||||
|
|
||||||
|
// Date/timestamp columns (createdAt, updatedAt, ...) — the filter sheet's
|
||||||
|
// picklist values are whole calendar days (e.g. "2026-07-16"), but the
|
||||||
|
// column itself is a full timestamp. Casting the timestamp to TEXT and
|
||||||
|
// doing a substring iLike match against just the date portion is a loose
|
||||||
|
// match: it also picks up every OTHER row whose time-of-day component
|
||||||
|
// happens to render into digits that appear elsewhere in the cast string,
|
||||||
|
// so a single selected day can silently pull in unrelated rows. Match by
|
||||||
|
// an explicit [dayStart, nextDayStart) range on the real column instead —
|
||||||
|
// exact, and immune to how the DB happens to stringify the timestamp.
|
||||||
|
if (dateFields.has(id) && !id.startsWith("personal_info.")) {
|
||||||
|
const conditions = values.map((v) => {
|
||||||
|
const datePart = String(v).slice(0, 10);
|
||||||
|
const dayStart = new Date(`${datePart}T00:00:00.000Z`);
|
||||||
|
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return Sequelize.where(Sequelize.col(qualifiedCol), {
|
||||||
|
[Op.gte]: dayStart,
|
||||||
|
[Op.lt]: dayEnd,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
where.push({ [Op.or]: conditions });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const conditions = values.map((v) =>
|
const conditions = values.map((v) =>
|
||||||
id.startsWith("personal_info.")
|
id.startsWith("personal_info.")
|
||||||
? Sequelize.where(
|
? Sequelize.where(
|
||||||
@@ -114,17 +141,19 @@ function buildOrder(sort = [], allowedFields = new Set(), computedFields = new S
|
|||||||
*
|
*
|
||||||
* @param {Array} filters
|
* @param {Array} filters
|
||||||
* @param {Array} sort
|
* @param {Array} sort
|
||||||
|
* @param {Array} [dateFields] - fields to match by day-range instead of substring
|
||||||
* @returns {{ where: Object, order: Array }}
|
* @returns {{ where: Object, order: Array }}
|
||||||
*/
|
*/
|
||||||
function buildQuery(filters = [], sort = [], allowedFields = [], computedFields = [], parentAlias = null) {
|
function buildQuery(filters = [], sort = [], allowedFields = [], computedFields = [], parentAlias = null, dateFields = []) {
|
||||||
const fieldSet = new Set(allowedFields);
|
const fieldSet = new Set(allowedFields);
|
||||||
const computedSet = new Set(computedFields);
|
const computedSet = new Set(computedFields);
|
||||||
const orderFieldSet = new Set([...allowedFields, ...computedFields]);
|
const orderFieldSet = new Set([...allowedFields, ...computedFields]);
|
||||||
|
const dateFieldSet = new Set(dateFields);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Computed (subquery/literal) columns aren't real table columns — filtering
|
// Computed (subquery/literal) columns aren't real table columns — filtering
|
||||||
// via Sequelize.col() would error, so only allow them in ORDER BY, not WHERE.
|
// via Sequelize.col() would error, so only allow them in ORDER BY, not WHERE.
|
||||||
where: buildWhere(filters, fieldSet, parentAlias),
|
where: buildWhere(filters, fieldSet, parentAlias, dateFieldSet),
|
||||||
order: buildOrder(sort, orderFieldSet, computedSet, parentAlias),
|
order: buildOrder(sort, orderFieldSet, computedSet, parentAlias),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,11 +110,12 @@ async function paginate(model, req, {
|
|||||||
const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas, context });
|
const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas, context });
|
||||||
const ALLOWED_FIELDS = attributes.map((a) => a.field);
|
const ALLOWED_FIELDS = attributes.map((a) => a.field);
|
||||||
const computedFieldKeys = computedAttributes.map((c) => c.key);
|
const computedFieldKeys = computedAttributes.map((c) => c.key);
|
||||||
|
const dateFieldKeys = attributes.filter((a) => a.type === 'date').map((a) => a.field);
|
||||||
|
|
||||||
// Sequelize aliases the main model's table with the model's name by default
|
// Sequelize aliases the main model's table with the model's name by default
|
||||||
// (e.g. `FROM "users" AS "User"`) — needed to qualify Sequelize.col()
|
// (e.g. `FROM "users" AS "User"`) — needed to qualify Sequelize.col()
|
||||||
// references so they don't collide with same-named columns on joined tables.
|
// references so they don't collide with same-named columns on joined tables.
|
||||||
const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS, computedFieldKeys, model.name);
|
const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS, computedFieldKeys, model.name, dateFieldKeys);
|
||||||
|
|
||||||
// Build attribute includes: jsonb + audit subqueries + any extra from findOptions
|
// Build attribute includes: jsonb + audit subqueries + any extra from findOptions
|
||||||
const baseIncludes = jsonbAttr ? [jsonbAttr] : [];
|
const baseIncludes = jsonbAttr ? [jsonbAttr] : [];
|
||||||
|
|||||||
Reference in New Issue
Block a user