Files
starr-philproperties/apps/api/controllers/admin/task_completion.controller.js

347 lines
16 KiB
JavaScript

/***********************************************************************************************************************************************************************
* File Name: task_completion.controller.js (admin)
* Type of Program: Controller
* Description: Admin-level task completion management.
* Admins can view all completions per task, view a single completion,
* and archive/restore completions. Completions are created by clients only.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { Op, Sequelize } = require('sequelize');
const sequelize = require('../../config/db.config');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
const { Task } = require('../../models/task/task.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { checkTaskCompletion, fireTaskCompletedEvent } = require('../client/task.controller');
const { adminExclude } = require('../../models/task/task_completion.attributes');
const R = require('../../utils/response.util');
const { paginate } = require('../../utils/paginate.util');
const { archiveOne, archiveMany } = require('../../utils/courses/archive.util');
const { restoreOne, restoreMany } = require('../../utils/courses/restore.util');
const logActivity = require('../../utils/logActivity.util');
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
const COMPLETION_FIELDS = ['submitted_at', 'createdAt', 'updatedAt', 'deletedAt'];
// ─── Reusable include: completion files ───────────────────────────────────────
// separate: true → Sequelize fetches files in a second SELECT ... WHERE completion_id IN (...)
// instead of a JOIN, which avoids the subquery alias conflict that occurs when
// paginate applies LIMIT/OFFSET alongside a hasMany include.
const FILES_INCLUDE = {
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: adminExclude },
paranoid: false,
separate: true,
order: [['createdAt', 'ASC']],
};
// ─── Reusable include: submitting user ────────────────────────────────────────
const USER_INCLUDE = {
model: mdl_Users,
as: 'user',
attributes: [
'user_id',
'email', // ← direct column, fine as-is
[
Sequelize.literal(`("user"."personal_info"->'name'->>'full_name')`),
'name',
],
],
};
// =============================================================================
// ── COMPLETIONS (nested under task-list → task) ───────────────────────────────
// =============================================================================
// ─── GET ALL ──────────────────────────────────────────────────────────────────
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions
exports.getCompletions = async (req, res) => {
try {
const { taskListId, taskId } = req.params;
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) return R.error(res, 'Task not found.', 404);
const result = await paginate(TaskCompletion, req, {
excludeAttributes: adminExclude,
jsonbSchemas: {},
computedAttributes: [],
auditOptions: { mdl_Users, parentAlias: 'TaskCompletion' },
allowedFields: COMPLETION_FIELDS,
findOptions: {
where: { task_id: taskId },
include: [USER_INCLUDE, FILES_INCLUDE],
},
});
return R.success(res, 'Completions retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET ALL COMPLETIONS]', err);
return R.error(res, 'Could not retrieve completions.', 500);
}
};
// ─── GET ONE ──────────────────────────────────────────────────────────────────
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId
exports.getCompletion = async (req, res) => {
try {
const { taskListId, taskId, completionId } = req.params;
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: { completion_id: completionId, task_id: taskId },
attributes: { exclude: adminExclude },
paranoid: false,
include: [USER_INCLUDE, FILES_INCLUDE],
});
if (!completion) return R.error(res, 'Completion not found.', 404);
return R.success(res, 'Completion retrieved.', completion);
} catch (err) {
console.error('[ADMIN][GET COMPLETION]', err);
return R.error(res, 'Could not retrieve completion.', 500);
}
};
// ─── GET ALL BY USER ──────────────────────────────────────────────────────────
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId
exports.getCompletionsByUser = async (req, res) => {
try {
const { taskListId, taskId, userId } = req.params;
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) return R.error(res, 'Task not found.', 404);
const result = await paginate(TaskCompletion, req, {
excludeAttributes: adminExclude,
jsonbSchemas: {},
computedAttributes: [],
auditOptions: { mdl_Users, parentAlias: 'TaskCompletion' },
allowedFields: COMPLETION_FIELDS,
findOptions: {
where: { task_id: taskId, user_id: userId },
include: [FILES_INCLUDE],
},
});
return R.success(res, 'User completions retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET COMPLETIONS BY USER]', err);
return R.error(res, 'Could not retrieve user completions.', 500);
}
};
// ─── REVIEW ───────────────────────────────────────────────────────────────────
// PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/review
// Approves/rejects a submission for a requirement flagged requires_review.
// Notifies the submitting learner via the existing template pattern (same shape
// as updateTask's task_requirements_updated notify block).
exports.reviewSubmission = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId, completionId } = req.params;
const { status, review_note } = req.body;
if (!['approved', 'rejected'].includes(status)) {
await t.rollback();
return R.error(res, 'status must be "approved" or "rejected".', 400);
}
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); }
const completion = await TaskCompletion.findOne({
where: { completion_id: completionId, task_id: taskId },
transaction: t,
});
if (!completion) { await t.rollback(); return R.error(res, 'Completion not found.', 404); }
const wasComplete = status === 'approved' ? await checkTaskCompletion(completion.user_id, taskId) : false;
await completion.update({
status,
review_note: review_note || null,
reviewed_by: req.user.user_id,
reviewed_at: new Date(),
updatedBy: req.user.user_id,
}, { transaction: t });
await t.commit();
if (status === 'approved' && !wasComplete && await checkTaskCompletion(completion.user_id, taskId)) {
fireTaskCompletedEvent(completion.user_id, taskId); // fire-and-forget
}
logActivity(req.user.user_id, 'review_task_submission', {
entityType: 'task_completion', entityId: completionId, details: { task_id: taskId, status },
});
try {
const notify = NOTIFICATION_REGISTRY.task_submission_reviewed.build({
taskName: task.name, status, review_note: review_note || null,
});
await UserNotification.create({ user_id: completion.user_id, ...notify, seen: false });
} catch (notifyErr) {
console.error('[ADMIN][REVIEW SUBMISSION][NOTIFY]', notifyErr);
}
return R.success(res, 'Submission reviewed.', completion);
} catch (err) {
await t.rollback();
console.error('[ADMIN][REVIEW SUBMISSION]', err);
return R.error(res, 'Could not review submission.', 500);
}
};
// ─── ARCHIVE ──────────────────────────────────────────────────────────────────
// DELETE /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId
exports.archiveCompletion = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId, completionId } = req.params;
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); }
const record = await archiveOne(
TaskCompletion,
{ completion_id: completionId, task_id: taskId },
req.user.user_id,
t
);
if (!record) { await t.rollback(); return R.error(res, 'Completion not found.', 404); }
await t.commit();
logActivity(req.user.user_id, 'archive_completion', { entityType: 'task_completion', entityId: Number(completionId), details: { task_id: taskId } });
return R.success(res, 'Completion archived successfully.');
} catch (err) {
await t.rollback();
console.error('[ADMIN][ARCHIVE COMPLETION]', err);
return R.error(res, 'Could not archive completion.', 500);
}
};
// ─── RESTORE ──────────────────────────────────────────────────────────────────
// PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore
exports.restoreCompletion = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId, completionId } = req.params;
const record = await restoreOne(
TaskCompletion,
{ completion_id: completionId, task_id: taskId, deletedAt: { [Op.not]: null } },
req.user.user_id,
t
);
if (!record) { await t.rollback(); return R.error(res, 'Completion not found or not archived.', 404); }
await t.commit();
logActivity(req.user.user_id, 'restore_completion', { entityType: 'task_completion', entityId: Number(completionId), details: { task_id: taskId } });
return R.success(res, 'Completion restored successfully.', record);
} catch (err) {
await t.rollback();
console.error('[ADMIN][RESTORE COMPLETION]', err);
return R.error(res, 'Could not restore completion.', 500);
}
};
// ─── BULK ARCHIVE ─────────────────────────────────────────────────────────────
// POST /admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive
exports.bulkArchiveCompletions = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId } = req.params;
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No completion IDs provided.', 400);
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
const completions = await TaskCompletion.findAll({
where: { completion_id: ids, task_id: taskId },
});
if (!completions.length) return R.error(res, 'No completions found.', 404);
const activeIds = completions
.filter((c) => !c.deletedAt)
.map((c) => c.completion_id);
if (!activeIds.length)
return R.error(res, 'All selected completions are already archived.', 400);
const count = await archiveMany(TaskCompletion, 'completion_id', activeIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_completions', { entityType: 'task_completion', details: { ids: activeIds, count, task_id: taskId } });
return R.success(res, `${count} completion(s) archived successfully.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} catch (err) {
await t.rollback();
console.error('[ADMIN][BULK ARCHIVE COMPLETIONS]', err);
return R.error(res, 'Could not archive completions.', 500);
}
};
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
// POST /admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore
exports.bulkRestoreCompletions = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId } = req.params;
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No completion IDs provided.', 400);
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
const completions = await TaskCompletion.findAll({
where: { completion_id: ids, task_id: taskId },
paranoid: false,
});
if (!completions.length) return R.error(res, 'No completions found.', 404);
const deletedIds = completions
.filter((c) => c.deletedAt)
.map((c) => c.completion_id);
if (!deletedIds.length)
return R.error(res, 'All selected completions are already active.', 400);
const count = await restoreMany(TaskCompletion, 'completion_id', deletedIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_completions', { entityType: 'task_completion', details: { ids: deletedIds, count, task_id: taskId } });
return R.success(res, `${count} completion(s) restored successfully.`, {
restored_ids: deletedIds,
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
});
} catch (err) {
await t.rollback();
console.error('[ADMIN][BULK RESTORE COMPLETIONS]', err);
return R.error(res, 'Could not restore completions.', 500);
}
};