mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,682 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 } = 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');
|
||||
|
||||
// =============================================================================
|
||||
// ── 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: ['joined_at'],
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
where: { is_active: true },
|
||||
attributes: ['group_id', 'name', 'group_code', '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;
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
|
||||
//
|
||||
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|done|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:
|
||||
// done → 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 done AND task.deadline < now
|
||||
// ongoing → otherwise
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getGroupTaskList = async (req, res) => {
|
||||
try {
|
||||
const { groupId, taskListId } = req.params;
|
||||
const { status } = req.query; // optional: 'ongoing' | 'done' | '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: [['createdAt', '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);
|
||||
|
||||
// ── 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 now = Date.now();
|
||||
|
||||
// ── Bucket each task by per-requirement completion ──────────────────────
|
||||
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 has_completed = allRequirementsDone;
|
||||
|
||||
let bucket;
|
||||
if (has_completed) {
|
||||
bucket = 'done';
|
||||
} else if (task.deadline && new Date(task.deadline).getTime() < now) {
|
||||
bucket = 'overdue';
|
||||
} else {
|
||||
bucket = 'ongoing';
|
||||
}
|
||||
|
||||
return { ...task, has_completed, _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|done|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 → Done
|
||||
// NOT all done 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' | 'done' | '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: [['createdAt', '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);
|
||||
|
||||
// ── 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 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) => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
|
||||
let bucket;
|
||||
if (tasks.length === 0) {
|
||||
bucket = 'ongoing';
|
||||
} else {
|
||||
const allDone = tasks.every((t) => t.has_completed);
|
||||
if (allDone) {
|
||||
bucket = 'done';
|
||||
} 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;
|
||||
|
||||
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;
|
||||
|
||||
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 = [] } = 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 (!files.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'At least one file 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);
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
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,
|
||||
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 });
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user