mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
units,lesson as standalone
This commit is contained in:
@@ -17,12 +17,16 @@ const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_c
|
||||
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { QuizAttempt } = require('../../models/courses/courses.associations');
|
||||
|
||||
const { userExclude } = require('../../models/task/task.attributes');
|
||||
const { clientExclude } = require('../../models/task/task_completion.attributes');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { renderNotification } = require('../../services/notificationTemplate.service');
|
||||
const { onTaskCompleted, onTaskListCompleted } = require('../../services/achievements.service');
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const isUUID = (v) => UUID_RE.test(v);
|
||||
@@ -129,6 +133,150 @@ const isMember = async (userId, groupId) => {
|
||||
return !!membership;
|
||||
};
|
||||
|
||||
// ─── Helper: per-user completion signals for a batch of tasks ─────────────────
|
||||
// Shared by getGroupTaskList/getGroupTaskLists. upload_file/submit_text share
|
||||
// one TaskCompletion per task (resubmit-anytime — latest by submitted_at wins);
|
||||
// pass_quiz is computed live from QuizAttempt, same as unit-quiz has_passed
|
||||
// (courses.controller.js) rather than a separately-synced TaskProgress row.
|
||||
const getTaskCompletionSignals = async (userId, taskIds, requirements) => {
|
||||
const quizIds = [...new Set(
|
||||
requirements.filter((r) => r.type === 'pass_quiz' && r.reference_id).map((r) => r.reference_id)
|
||||
)];
|
||||
|
||||
const [completions, linkVisits, progressRows, passedAttempts] = await Promise.all([
|
||||
taskIds.length
|
||||
? TaskCompletion.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
|
||||
attributes: ['task_id', 'status', 'submitted_at'],
|
||||
order: [['submitted_at', 'DESC']],
|
||||
})
|
||||
: [],
|
||||
taskIds.length
|
||||
? TaskLinkVisit.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
|
||||
attributes: ['task_id', 'requirement_id'],
|
||||
})
|
||||
: [],
|
||||
taskIds.length
|
||||
? TaskProgress.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
|
||||
attributes: ['task_id', 'requirement_id', 'reference_id'],
|
||||
})
|
||||
: [],
|
||||
quizIds.length
|
||||
? QuizAttempt.findAll({
|
||||
where: { quiz_id: { [Op.in]: quizIds }, user_id: userId, passed: true },
|
||||
attributes: ['quiz_id'],
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
// First row per task_id wins — completions are ordered submitted_at DESC.
|
||||
const latestCompletionByTask = new Map();
|
||||
for (const c of completions) {
|
||||
if (!latestCompletionByTask.has(c.task_id)) latestCompletionByTask.set(c.task_id, c);
|
||||
}
|
||||
|
||||
return {
|
||||
latestCompletionByTask,
|
||||
visitedRequirementIds: new Set(linkVisits.map((v) => v.requirement_id)),
|
||||
completedProgressKeys: new Set(progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)),
|
||||
passedQuizIds: new Set(passedAttempts.map((a) => String(a.quiz_id))),
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Helper: has this requirement been satisfied by the user? ─────────────────
|
||||
const isRequirementDone = (r, signals) => {
|
||||
switch (r.type) {
|
||||
case 'upload_file':
|
||||
case 'submit_text': {
|
||||
const completion = signals.latestCompletionByTask.get(r.task_id);
|
||||
if (!completion) return false;
|
||||
return r.requires_review ? completion.status === 'approved' : true;
|
||||
}
|
||||
case 'visit_link':
|
||||
return signals.visitedRequirementIds.has(r.requirement_id);
|
||||
case 'read_course':
|
||||
case 'read_unit':
|
||||
case 'read_lesson':
|
||||
return signals.completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
|
||||
case 'pass_quiz':
|
||||
return signals.passedQuizIds.has(String(r.reference_id));
|
||||
default:
|
||||
return true; // unknown requirement types don't block completion
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Helper: server-side sequencing gate ───────────────────────────────────
|
||||
// Rejects a completion write if any earlier *required* task in the same list
|
||||
// isn't complete yet — the enforcement piece the unit-quiz sequencing
|
||||
// precedent (UnitList.jsx) does NOT have (that one is client-lock-only).
|
||||
// Shared by this file's submitTask and task_progress.controller.js's
|
||||
// visitLink/updateProgress.
|
||||
const assertTaskUnlocked = async (userId, taskListId, orderIndex) => {
|
||||
const earlierRequired = await Task.findAll({
|
||||
where: { task_list_id: taskListId, order_index: { [Op.lt]: orderIndex }, is_required: true },
|
||||
include: [{ model: TaskRequirement, as: 'requirements' }],
|
||||
});
|
||||
if (!earlierRequired.length) return true;
|
||||
|
||||
const taskIds = earlierRequired.map((t) => t.task_id);
|
||||
const allRequirements = earlierRequired.flatMap((t) => (t.requirements ?? []).map((r) => r.toJSON()));
|
||||
const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements);
|
||||
|
||||
return earlierRequired.every((t) => {
|
||||
const reqs = (t.requirements ?? []);
|
||||
return reqs.length > 0 && reqs.every((r) => isRequirementDone(r.toJSON ? r.toJSON() : r, signals));
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Helper: is this one task fully done for this user, right now? ─────────
|
||||
const checkTaskCompletion = async (userId, taskId) => {
|
||||
const reqs = await TaskRequirement.findAll({ where: { task_id: taskId } });
|
||||
if (!reqs.length) return false;
|
||||
const plainReqs = reqs.map((r) => r.toJSON());
|
||||
const signals = await getTaskCompletionSignals(userId, [taskId], plainReqs);
|
||||
return plainReqs.every((r) => isRequirementDone(r, signals));
|
||||
};
|
||||
|
||||
// ─── Helper: fire task_completed (+ task_list_finisher achievement) on the
|
||||
// 0→1 completion transition. Callers compute `wasComplete` themselves right
|
||||
// before their write, then call this after, so it only fires once per task.
|
||||
const fireTaskCompletedEvent = async (userId, taskId) => {
|
||||
try {
|
||||
const task = await Task.findByPk(taskId);
|
||||
if (!task) return;
|
||||
|
||||
try {
|
||||
const notify = await renderNotification({ type: 'task_completed', data: { taskName: task.name } });
|
||||
await UserNotification.create({ user_id: userId, ...notify, seen: false });
|
||||
} catch (notifyErr) {
|
||||
console.error('[TASK][NOTIFY COMPLETED]', notifyErr);
|
||||
}
|
||||
|
||||
await onTaskCompleted(userId, taskId, task.name);
|
||||
|
||||
// ── Whole-list completion — check every sibling task too ───────────
|
||||
const siblingTasks = await Task.findAll({ where: { task_list_id: task.task_list_id } });
|
||||
const allDone = siblingTasks.length > 0 && (
|
||||
await Promise.all(siblingTasks.map((t) => checkTaskCompletion(userId, t.task_id)))
|
||||
).every(Boolean);
|
||||
|
||||
if (allDone) {
|
||||
const taskList = await TaskList.findByPk(task.task_list_id);
|
||||
if (taskList) await onTaskListCompleted(userId, taskList.task_list_id, taskList.name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TASK][FIRE COMPLETED EVENT]', err);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getTaskCompletionSignals = getTaskCompletionSignals;
|
||||
exports.isRequirementDone = isRequirementDone;
|
||||
exports.assertTaskUnlocked = assertTaskUnlocked;
|
||||
exports.checkTaskCompletion = checkTaskCompletion;
|
||||
exports.fireTaskCompletedEvent = fireTaskCompletedEvent;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
|
||||
//
|
||||
@@ -185,7 +333,7 @@ exports.getGroupTaskList = async (req, res) => {
|
||||
attributes: { exclude: userExclude },
|
||||
order: [['order', 'ASC']],
|
||||
}],
|
||||
order: [['createdAt', 'ASC']],
|
||||
order: [['order_index', 'ASC']],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -204,35 +352,8 @@ exports.getGroupTaskList = async (req, res) => {
|
||||
await hydrateReadTaskProgress(userId, readRequirements);
|
||||
|
||||
// ── Fetch user's completion signals for these tasks ────────────────────
|
||||
const [completions, linkVisits, progressRows] = await Promise.all([
|
||||
taskIds.length
|
||||
? TaskCompletion.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
|
||||
attributes: ['task_id'],
|
||||
})
|
||||
: [],
|
||||
taskIds.length
|
||||
? TaskLinkVisit.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
|
||||
attributes: ['task_id', 'requirement_id'],
|
||||
})
|
||||
: [],
|
||||
taskIds.length
|
||||
? TaskProgress.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
|
||||
attributes: ['task_id', 'requirement_id', 'reference_id'],
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
// ── Lookup sets/maps for per-requirement checks ─────────────────────────
|
||||
const tasksWithCompletion = new Set(completions.map((c) => c.task_id));
|
||||
|
||||
const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id));
|
||||
|
||||
const completedProgressKeys = new Set(
|
||||
progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)
|
||||
);
|
||||
const allRequirements = tasks.flatMap((task) => task.requirements ?? []);
|
||||
const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
@@ -240,20 +361,8 @@ exports.getGroupTaskList = async (req, res) => {
|
||||
const bucketedTasks = tasks.map((task) => {
|
||||
const requirements = task.requirements ?? [];
|
||||
|
||||
const allRequirementsDone = requirements.length > 0 && requirements.every((r) => {
|
||||
switch (r.type) {
|
||||
case 'upload_file':
|
||||
return tasksWithCompletion.has(task.task_id);
|
||||
case 'visit_link':
|
||||
return visitedRequirementIds.has(r.requirement_id);
|
||||
case 'read_course':
|
||||
case 'read_unit':
|
||||
case 'read_lesson':
|
||||
return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
|
||||
default:
|
||||
return true; // unknown requirement types don't block completion
|
||||
}
|
||||
});
|
||||
const allRequirementsDone = requirements.length > 0 &&
|
||||
requirements.every((r) => isRequirementDone(r, signals));
|
||||
|
||||
const has_completed = allRequirementsDone;
|
||||
|
||||
@@ -333,7 +442,7 @@ exports.getGroupTaskLists = async (req, res) => {
|
||||
order: [['order', 'ASC']],
|
||||
},
|
||||
],
|
||||
order: [['createdAt', 'ASC']],
|
||||
order: [['order_index', 'ASC']],
|
||||
},
|
||||
],
|
||||
attributes: { exclude: userExclude },
|
||||
@@ -357,33 +466,8 @@ exports.getGroupTaskLists = async (req, res) => {
|
||||
await hydrateReadTaskProgress(userId, readRequirements);
|
||||
|
||||
// ── Fetch user's completion signals for these tasks ────────────────────
|
||||
const [completions, linkVisits, progressRows] = await Promise.all([
|
||||
taskIds.length
|
||||
? TaskCompletion.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
|
||||
attributes: ['task_id'],
|
||||
})
|
||||
: [],
|
||||
taskIds.length
|
||||
? TaskLinkVisit.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
|
||||
attributes: ['task_id', 'requirement_id'],
|
||||
})
|
||||
: [],
|
||||
taskIds.length
|
||||
? TaskProgress.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
|
||||
attributes: ['task_id', 'requirement_id', 'reference_id'],
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
// ── Lookup sets/maps for per-requirement checks ─────────────────────────
|
||||
const tasksWithCompletion = new Set(completions.map((c) => c.task_id));
|
||||
const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id));
|
||||
const completedProgressKeys = new Set(
|
||||
progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)
|
||||
);
|
||||
const allRequirements = allTasks.flatMap((task) => task.requirements ?? []);
|
||||
const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
@@ -392,20 +476,7 @@ exports.getGroupTaskLists = async (req, res) => {
|
||||
const requirements = task.requirements ?? [];
|
||||
if (requirements.length === 0) return false; // vacuously not done
|
||||
|
||||
return requirements.every((r) => {
|
||||
switch (r.type) {
|
||||
case 'upload_file':
|
||||
return tasksWithCompletion.has(task.task_id);
|
||||
case 'visit_link':
|
||||
return visitedRequirementIds.has(r.requirement_id);
|
||||
case 'read_course':
|
||||
case 'read_unit':
|
||||
case 'read_lesson':
|
||||
return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return requirements.every((r) => isRequirementDone(r, signals));
|
||||
};
|
||||
|
||||
// ── Bucket each task list based on per-task has_completed ───────────────
|
||||
@@ -576,7 +647,7 @@ exports.submitTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { groupId, taskListId, taskId } = req.params;
|
||||
const { note, files = [] } = req.body;
|
||||
const { note, files = [], response_text } = req.body;
|
||||
|
||||
const member = await isMember(req.user.user_id, groupId);
|
||||
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
|
||||
@@ -584,10 +655,33 @@ exports.submitTask = async (req, res) => {
|
||||
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
|
||||
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
|
||||
if (!files.length) {
|
||||
if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Complete the earlier required tasks in this list first.', 409);
|
||||
}
|
||||
|
||||
const wasComplete = await checkTaskCompletion(req.user.user_id, taskId);
|
||||
|
||||
// ── Which submission-based requirement(s) does this task have? ─────────
|
||||
const submissionRequirements = await TaskRequirement.findAll({
|
||||
where: { task_id: taskId, type: { [Op.in]: ['upload_file', 'submit_text'] } },
|
||||
transaction: t,
|
||||
});
|
||||
const uploadRequirement = submissionRequirements.find((r) => r.type === 'upload_file');
|
||||
const textRequirement = submissionRequirements.find((r) => r.type === 'submit_text');
|
||||
|
||||
if (!uploadRequirement && !textRequirement) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'This task has no requirement that accepts a submission.', 400);
|
||||
}
|
||||
if (uploadRequirement && !files.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'At least one file is required to submit.', 400);
|
||||
}
|
||||
if (!uploadRequirement && textRequirement && !(response_text ?? '').trim()) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'A response is required to submit.', 400);
|
||||
}
|
||||
|
||||
// Validate file entries have required fields
|
||||
const invalid = files.some((f) => !f.file_url || !f.file_name);
|
||||
@@ -596,12 +690,6 @@ exports.submitTask = async (req, res) => {
|
||||
return R.error(res, 'Each file must have file_url and file_name.', 400);
|
||||
}
|
||||
|
||||
// ── Validate against upload_file requirement (if defined) ──────────────
|
||||
const uploadRequirement = await TaskRequirement.findOne({
|
||||
where: { task_id: taskId, type: 'upload_file' },
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
if (uploadRequirement) {
|
||||
// ── max_file_count ───────────────────────────────────────────────────
|
||||
const maxFiles = uploadRequirement.max_file_count;
|
||||
@@ -641,22 +729,25 @@ exports.submitTask = async (req, res) => {
|
||||
task_id: taskId,
|
||||
user_id: req.user.user_id,
|
||||
note: note || null,
|
||||
response_text: response_text || null,
|
||||
submitted_at: new Date(),
|
||||
createdBy: req.user.user_id,
|
||||
updatedBy: req.user.user_id,
|
||||
}, { transaction: t });
|
||||
|
||||
const fileRows = files.map((f) => ({
|
||||
completion_id: completion.completion_id,
|
||||
file_url: f.file_url,
|
||||
file_name: f.file_name,
|
||||
file_size: f.file_size ?? null,
|
||||
mime_type: f.mime_type ?? null,
|
||||
storage_key: f.storage_key ?? null,
|
||||
createdBy: req.user.user_id,
|
||||
updatedBy: req.user.user_id,
|
||||
}));
|
||||
await TaskCompletionFile.bulkCreate(fileRows, { transaction: t });
|
||||
if (files.length) {
|
||||
const fileRows = files.map((f) => ({
|
||||
completion_id: completion.completion_id,
|
||||
file_url: f.file_url,
|
||||
file_name: f.file_name,
|
||||
file_size: f.file_size ?? null,
|
||||
mime_type: f.mime_type ?? null,
|
||||
storage_key: f.storage_key ?? null,
|
||||
createdBy: req.user.user_id,
|
||||
updatedBy: req.user.user_id,
|
||||
}));
|
||||
await TaskCompletionFile.bulkCreate(fileRows, { transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
@@ -675,6 +766,10 @@ exports.submitTask = async (req, res) => {
|
||||
entityId: Number(taskId),
|
||||
});
|
||||
|
||||
if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) {
|
||||
fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget
|
||||
}
|
||||
|
||||
return R.success(res, 'Task submitted successfully.', full, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
|
||||
Reference in New Issue
Block a user