Files
starr-philproperties/controllers/client/task.controller.js
T
kennethobsequio 439bb33f77 ready to test
Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-22 10:06:58 +08:00

682 lines
30 KiB
JavaScript

/***********************************************************************************************************************************************************************
* 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);
}
};