chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
@@ -0,0 +1,851 @@
/***********************************************************************************************************************************************************************
* File Name: task.controller.js (client)
* Type of Program: Controller
* Description: Client-level task access.
* Users can view groups they belong to, task lists assigned to those
* groups, tasks within those lists, and submit work for tasks.
* Read-only except for completions.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const sequelize = require('../../config/db.config');
const { Task, TaskList, TaskRequirement, TaskListGroup, TaskPrerequisite } = require('../../models/task/task.mdl');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
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 { 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 { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
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);
// =============================================================================
// ── GROUPS ────────────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET MY GROUPS ────────────────────────────────────────────────────────────
// GET /client/groups
// Returns all active groups the authenticated user belongs to.
exports.getMyGroups = async (req, res) => {
try {
const groups = await mdl_UserGroups.findAll({
include: [
{
model: mdl_Users,
as: 'members',
where: { user_id: req.user.user_id },
attributes: [],
through: {
model: mdl_UserGroupMembers,
attributes: [],
where: { deletedAt: null },
},
},
{
model: TaskList,
as: 'taskLists',
attributes: [],
through: { attributes: [] },
required: false,
},
],
where: { is_active: true },
attributes: [
'group_id',
'name',
'group_code',
'description',
[sequelize.fn('COUNT', sequelize.fn('DISTINCT', sequelize.col('taskLists.task_list_id'))), 'task_list_count'],
],
group: [
'UserGroup.group_id',
'UserGroup.name',
'UserGroup.group_code',
'UserGroup.description',
],
order: [['name', 'ASC']],
});
return R.success(res, 'Groups retrieved.', groups);
} catch (err) {
console.error('[CLIENT][GET MY GROUPS]', err);
return R.error(res, 'Could not retrieve groups.', 500);
}
};
// ─── GET ONE GROUP ────────────────────────────────────────────────────────────
// GET /client/groups/:groupId
// Returns group info — verifies the user is a member before responding.
exports.getMyGroup = async (req, res) => {
try {
const { groupId } = req.params;
const group = await mdl_UserGroups.findOne({
where: { group_id: groupId, is_active: true },
attributes: ['group_id', 'name', 'group_code', 'description'],
include: [
{
model: mdl_Users,
as: 'members',
where: { user_id: req.user.user_id },
attributes: [],
through: {
model: mdl_UserGroupMembers,
attributes: [],
where: { deletedAt: null },
},
},
],
});
if (!group) return R.error(res, 'Group not found or you are not a member.', 404);
return R.success(res, 'Group retrieved.', group);
} catch (err) {
console.error('[CLIENT][GET MY GROUP]', err);
return R.error(res, 'Could not retrieve group.', 500);
}
};
// =============================================================================
// ── TASK LISTS ────────────────────────────────────────────────────────────────
// =============================================================================
// ─── Helper: verify user is member of group ───────────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
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).
const getTaskCompletionSignals = async (userId, taskIds) => {
const [completions, linkVisits, progressRows] = 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'],
})
: [],
]);
// 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}`)),
};
};
// ─── 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}`);
default:
return true; // unknown requirement types don't block completion
}
};
// ─── Helper: resolve which sibling task(s) are blocking a locked task ───────
// Prerequisites are always siblings within the same task list (enforced by
// admin's syncTaskPrerequisites), so names can always be resolved from `arr`.
// A task with no explicit task_prerequisites rows is never locked.
const resolveLockedBy = (task, i, arr, prereqsByTask, completedById) => {
const prereqIds = prereqsByTask.get(task.task_id);
if (!prereqIds || !prereqIds.length) return [];
const blockers = arr.filter((t) => prereqIds.includes(t.task_id) && completedById.get(t.task_id) !== true);
return blockers.map((t) => ({ task_id: t.task_id, name: t.name }));
};
// ─── Helper: explicit prerequisite gate ─────────────────────────────────────
// Returns true/false when `taskId` has explicit task_prerequisites rows —
// ALL of them must be completed by this user. Returns true (unlocked) when
// the task has no explicit prerequisites configured.
const checkPrerequisitesUnlocked = async (userId, taskId) => {
const prereqRows = await TaskPrerequisite.findAll({ where: { task_id: taskId } });
if (!prereqRows.length) return true;
const results = await Promise.all(prereqRows.map((r) => checkTaskCompletion(userId, r.prerequisite_task_id)));
return results.every(Boolean);
};
// ─── Helper: server-side sequencing gate ───────────────────────────────────
// A task is locked only by its own explicit task_prerequisites rows — no
// implicit locking based on list position. Shared by this file's submitTask
// and task_progress.controller.js's visitLink/updateProgress.
const assertTaskUnlocked = async (userId, taskId) => checkPrerequisitesUnlocked(userId, taskId);
// ─── 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]);
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 = NOTIFICATION_REGISTRY.task_completed.build({ 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.checkPrerequisitesUnlocked = checkPrerequisitesUnlocked;
exports.checkTaskCompletion = checkTaskCompletion;
exports.fireTaskCompletedEvent = fireTaskCompletedEvent;
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
//
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|completed|overdue
//
// has_completed is now computed per-task as: ALL of the task's requirements
// individually have a completion signal — matching RequirementsStatusPanel's
// "Overall progress: X / Y done" logic exactly.
//
// Per-requirement-type completion:
// upload_file → task has at least one TaskCompletion (binary, task-level)
// visit_link → a TaskLinkVisit exists for THIS requirement_id
// read_course/
// read_unit/
// read_lesson → a TaskProgress with completed=true exists for THIS
// requirement_id (+ reference_id)
//
// Task bucket:
// completed → every requirement passes its check above
// (a task with zero requirements is vacuously "ongoing", per
// earlier spec — zero requirements should not normally happen)
// overdue → not completed AND task.deadline < now
// ongoing → otherwise
// ─────────────────────────────────────────────────────────────────────────────
exports.getGroupTaskList = async (req, res) => {
try {
const { groupId, taskListId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'completed' | 'overdue'
const userId = req.user.user_id;
const member = await isMember(userId, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const taskList = await TaskList.findOne({
where: { task_list_id: taskListId },
attributes: { exclude: userExclude },
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
{
model: Task,
as: 'tasks',
required: false,
attributes: { exclude: userExclude },
include: [{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
}],
order: [['order_index', 'ASC']],
},
],
});
if (!taskList) return R.error(res, 'Task list not found or not assigned to your group.', 404);
const json = taskList.toJSON();
const tasks = json.tasks ?? [];
const taskIds = tasks.map((t) => t.task_id);
const readRequirements = tasks.flatMap((task) =>
(task.requirements ?? [])
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
.map((req) => ({ ...req, task_id: task.task_id }))
);
await hydrateReadTaskProgress(userId, readRequirements);
// ── Fetch user's completion signals for these tasks ────────────────────
const signals = await getTaskCompletionSignals(userId, taskIds);
const now = Date.now();
// ── has_completed per task (needed up-front — both the bucket AND the
// locked computation below depend on sibling tasks' completion) ────────
const completedById = new Map(tasks.map((task) => {
const requirements = task.requirements ?? [];
return [task.task_id, requirements.length > 0 && requirements.every((r) => isRequirementDone(r, signals))];
}));
// ── Explicit prerequisite edges for these tasks ─────────────────────────
const prereqEdges = taskIds.length
? await TaskPrerequisite.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: ['task_id', 'prerequisite_task_id'] })
: [];
const prereqsByTask = new Map();
for (const { task_id, prerequisite_task_id } of prereqEdges) {
if (!prereqsByTask.has(task_id)) prereqsByTask.set(task_id, []);
prereqsByTask.get(task_id).push(prerequisite_task_id);
}
// ── Bucket + lock each task — a task with explicit prerequisites is
// locked until ALL of them are done; a task with none is never locked
// (same rule assertTaskUnlocked enforces).
const bucketedTasks = tasks.map((task, i, arr) => {
const has_completed = completedById.get(task.task_id);
let bucket;
if (has_completed) {
bucket = 'completed';
} else if (task.deadline && new Date(task.deadline).getTime() < now) {
bucket = 'overdue';
} else {
bucket = 'ongoing';
}
const lockedBy = resolveLockedBy(task, i, arr, prereqsByTask, completedById);
const locked = lockedBy.length > 0;
return { ...task, has_completed, locked, lockedBy, _bucket: bucket };
});
// ── Filter by requested status, strip internal _bucket field ──────────
const filteredTasks = status
? bucketedTasks.filter((t) => t._bucket === status)
: bucketedTasks;
json.tasks = filteredTasks.map(({ _bucket, ...rest }) => rest);
return R.success(res, 'Task list retrieved.', json);
} catch (err) {
console.error('[CLIENT][GET GROUP TASK LIST]', err);
return R.error(res, 'Could not retrieve task list.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskLists in task.controller.js (client) — plural
//
// GET /client/groups/:groupId/task-lists?status=ongoing|completed|overdue
//
// Updated to match getGroupTaskList (singular): has_completed per task now
// means ALL of that task's requirements individually have a completion signal
// (not just "any"), matching RequirementsStatusPanel's "X / Y done" logic.
//
// TaskList bucket (based on per-task has_completed, computed below):
// TaskList has zero tasks → Ongoing (nothing to do yet)
// ALL tasks have has_completed → Completed
// NOT all completed AND any incomplete
// task has deadline < now → Overdue
// Otherwise → Ongoing
// ─────────────────────────────────────────────────────────────────────────────
exports.getGroupTaskLists = async (req, res) => {
try {
const { groupId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'completed' | 'overdue'
const userId = req.user.user_id;
const member = await isMember(userId, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
// ── Fetch ALL task lists assigned to this group, no status filter ─────
const taskLists = await TaskList.findAll({
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
{
model: Task,
as: 'tasks',
required: false,
attributes: { exclude: userExclude },
include: [
{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
},
],
order: [['order_index', 'ASC']],
},
],
attributes: { exclude: userExclude },
order: [['createdAt', 'ASC']],
});
// ── Gather all task_ids across the group's task lists ──────────────────
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
const taskIds = allTasks.map((t) => t.task_id);
const readRequirements = allTasks.flatMap((task) =>
(task.requirements ?? [])
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
.map((req) => ({
task_id: task.task_id,
requirement_id: req.requirement_id,
reference_id: req.reference_id,
type: req.type,
}))
);
await hydrateReadTaskProgress(userId, readRequirements);
// ── Fetch user's completion signals for these tasks ────────────────────
const signals = await getTaskCompletionSignals(userId, taskIds);
const now = Date.now();
// ── Compute per-task has_completed via per-requirement checks ──────────
const computeHasCompleted = (task) => {
const requirements = task.requirements ?? [];
if (requirements.length === 0) return false; // vacuously not done
return requirements.every((r) => isRequirementDone(r, signals));
};
// ── Explicit prerequisite edges for these tasks ─────────────────────────
const prereqEdges = taskIds.length
? await TaskPrerequisite.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: ['task_id', 'prerequisite_task_id'] })
: [];
const prereqsByTask = new Map();
for (const { task_id, prerequisite_task_id } of prereqEdges) {
if (!prereqsByTask.has(task_id)) prereqsByTask.set(task_id, []);
prereqsByTask.get(task_id).push(prerequisite_task_id);
}
// ── Bucket each task list based on per-task has_completed ───────────────
const bucketed = taskLists.map((tl) => {
const json = tl.toJSON();
const tasks = json.tasks ?? [];
tasks.forEach((task) => {
task.has_completed = computeHasCompleted(task);
});
// has_completed lookup scoped to THIS list's tasks (prerequisite
// edges only ever point at siblings within the same list).
const completedById = new Map(tasks.map((task) => [task.task_id, task.has_completed]));
tasks.forEach((task, i, arr) => {
task.lockedBy = resolveLockedBy(task, i, arr, prereqsByTask, completedById);
task.locked = task.lockedBy.length > 0;
});
let bucket;
if (tasks.length === 0) {
bucket = 'ongoing';
} else {
const allDone = tasks.every((t) => t.has_completed);
if (allDone) {
bucket = 'completed';
} else {
const anyOverdue = tasks.some((t) =>
!t.has_completed && t.deadline && new Date(t.deadline).getTime() < now
);
bucket = anyOverdue ? 'overdue' : 'ongoing';
}
}
return { ...json, tasks, _bucket: bucket };
});
// ── Filter by requested status, then strip internal _bucket field ──────
const filtered = status
? bucketed.filter((tl) => tl._bucket === status)
: bucketed;
const data = filtered.map(({ _bucket, ...rest }) => rest);
return R.success(res, 'Task lists retrieved.', data);
} catch (err) {
console.error('[CLIENT][GET GROUP TASK LISTS]', err);
return R.error(res, 'Could not retrieve task lists.', 500);
}
};
// =============================================================================
// ── TASKS ─────────────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET ONE TASK ─────────────────────────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId
//
// Returns the task with its requirements + the user's latest completion.
exports.getTask = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
if (!isUUID(taskId) || !isUUID(taskListId)) return R.error(res, 'Task not found.', 404);
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
attributes: { exclude: userExclude },
include: [
{
model: TaskList,
as: 'taskList',
attributes: { exclude: userExclude },
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
],
},
{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
},
{
// Latest completion by this user
model: TaskCompletion,
as: 'completions',
where: { user_id: req.user.user_id },
required: false,
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
separate: true,
order: [['createdAt', 'ASC']],
}],
order: [['submitted_at', 'DESC']],
limit: 1,
},
],
});
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
// Flatten: expose latest_completion directly instead of array
const data = task.toJSON();
data.latest_completion = data.completions?.[0] ?? null;
delete data.completions;
data.locked = !(await assertTaskUnlocked(req.user.user_id, taskId));
return R.success(res, 'Task retrieved.', data);
} catch (err) {
console.error('[CLIENT][GET TASK]', err);
return R.error(res, 'Could not retrieve task.', 500);
}
};
// =============================================================================
// ── SUBMISSIONS ───────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET MY SUBMISSIONS FOR A TASK ───────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions
// Returns all past completions by this user for this task (newest first).
exports.getMySubmissions = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) return R.error(res, 'Task not found.', 404);
const completions = await TaskCompletion.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
}],
order: [['submitted_at', 'DESC']],
});
return R.success(res, 'Completions retrieved.', completions);
} catch (err) {
console.error('[CLIENT][GET MY SUBMISSIONS]', err);
return R.error(res, 'Could not retrieve completions.', 500);
}
};
// ─── SUBMIT ───────────────────────────────────────────────────────────────────
//
// Adds validation against the task's `upload_file` TaskRequirement:
// - allowed_file_types: array of uppercase extensions (e.g. ["PDF","DOCX",...])
// - max_file_count: integer cap on number of files per completion
//
// Validation happens BEFORE creating the TaskCompletion row, at submit time only
// (not at /upload). If validation fails, the transaction is rolled back and a
// 400 is returned — the already-uploaded files remain orphaned in S3, which is
// acceptable per current design (no cleanup-on-reject requirement).
exports.submitTask = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId } = req.params;
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); }
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 (task.accepts_submissions === false) {
await t.rollback();
return R.error(res, 'This task no longer accepts submissions.', 409);
}
if (!(await assertTaskUnlocked(req.user.user_id, taskId))) {
await t.rollback();
return R.error(res, 'Complete this task\'s prerequisite tasks 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);
if (invalid) {
await t.rollback();
return R.error(res, 'Each file must have file_url and file_name.', 400);
}
if (uploadRequirement) {
// ── max_file_count ───────────────────────────────────────────────────
const maxFiles = uploadRequirement.max_file_count;
if (maxFiles && files.length > maxFiles) {
await t.rollback();
return R.error(
res,
`You can only submit up to ${maxFiles} file${maxFiles !== 1 ? 's' : ''} for this task.`,
400
);
}
// ── allowed_file_types ───────────────────────────────────────────────
const allowedTypes = (uploadRequirement.allowed_file_types ?? [])
.map((ext) => String(ext).toUpperCase());
if (allowedTypes.length) {
const rejected = files.filter((f) => {
const ext = (f.file_name.split('.').pop() ?? '').toUpperCase();
return !allowedTypes.includes(ext);
});
if (rejected.length) {
await t.rollback();
const rejectedNames = rejected.map((f) => f.file_name).join(', ');
return R.error(
res,
`These files are not allowed: ${rejectedNames}. Allowed types: ${allowedTypes.join(', ')}.`,
400
);
}
}
}
// ── Create completion ──────────────────────────────────────────────────
const completion = await TaskCompletion.create({
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 });
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();
// Return full completion with files
const full = await TaskCompletion.findByPk(completion.completion_id, {
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
}],
});
logActivity(req.user.user_id, 'submit_task', {
entityType: 'task',
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();
console.error('[CLIENT][SUBMIT TASK]', err);
return R.error(res, 'Could not submit task.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// ADD THIS to the bottom of task.controller.js (client)
// ─────────────────────────────────────────────────────────────────────────────
// ─── GET LATEST COMPLETION ────────────────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/latest
// Returns only the most recent completion for this user on this task.
// Returns null if the user has not submitted yet — that is valid.
exports.getLatestCompletion = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
});
if (!task) return R.error(res, 'Task not found.', 404);
const completion = await TaskCompletion.findOne({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
separate: true,
order: [['createdAt', 'ASC']],
}],
order: [['submitted_at', 'DESC']],
});
return R.success(res, 'Latest completion retrieved.', completion ?? null);
} catch (err) {
console.error('[CLIENT][GET LATEST COMPLETION]', err);
return R.error(res, 'Could not retrieve latest completion.', 500);
}
};