feat: menambahkan api untuk task project dan submission
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
const fs = require('fs');
|
||||
const taskService = require('../services/task.service');
|
||||
const { response, error } = require('../../../helpers/utils/wrapper');
|
||||
const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code');
|
||||
const { InternalServerError } = require('../../../helpers/error');
|
||||
const { isValidPayload } = require('../../../helpers/utils/validator');
|
||||
const {
|
||||
createTaskModel,
|
||||
updateTaskModel,
|
||||
submitTaskModel,
|
||||
} = require('../models/task.model');
|
||||
|
||||
const getActor = (req) => ({
|
||||
id: req.id,
|
||||
role: req.role,
|
||||
email: req.email,
|
||||
});
|
||||
|
||||
const cleanupUploadedFile = (file) => {
|
||||
if (file?.path && fs.existsSync(file.path)) {
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
};
|
||||
|
||||
class TaskController {
|
||||
async createTask(req, res) {
|
||||
try {
|
||||
const validatePayload = isValidPayload(req.body, createTaskModel);
|
||||
|
||||
if (validatePayload.err) {
|
||||
return response(
|
||||
res,
|
||||
'fail',
|
||||
{ err: validatePayload.err, data: null },
|
||||
'Invalid task data',
|
||||
ERROR.EXPECTATION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActor(req);
|
||||
const result = await taskService.createTask(
|
||||
req.params.id_project,
|
||||
validatePayload.data,
|
||||
actor
|
||||
);
|
||||
|
||||
if (result.err) {
|
||||
return response(res, 'fail', result);
|
||||
}
|
||||
|
||||
return response(res, 'success', result, 'Task created successfully', SUCCESS.CREATED);
|
||||
} catch (err) {
|
||||
return response(
|
||||
res,
|
||||
'fail',
|
||||
error(new InternalServerError(err.message)),
|
||||
'Unexpected error occurred',
|
||||
ERROR.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getTasksByProject(req, res) {
|
||||
try {
|
||||
const actor = getActor(req);
|
||||
const result = await taskService.getTasksByProject(req.params.id_project, actor);
|
||||
|
||||
if (result.err) {
|
||||
return response(res, 'fail', result);
|
||||
}
|
||||
|
||||
return response(res, 'success', result, 'Tasks retrieved successfully', SUCCESS.OK);
|
||||
} catch (err) {
|
||||
return response(
|
||||
res,
|
||||
'fail',
|
||||
error(new InternalServerError(err.message)),
|
||||
'Unexpected error occurred',
|
||||
ERROR.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getTaskById(req, res) {
|
||||
try {
|
||||
const actor = getActor(req);
|
||||
const result = await taskService.getTaskById(req.params.id, actor);
|
||||
|
||||
if (result.err) {
|
||||
return response(res, 'fail', result);
|
||||
}
|
||||
|
||||
return response(res, 'success', result, 'Task detail retrieved successfully', SUCCESS.OK);
|
||||
} catch (err) {
|
||||
return response(
|
||||
res,
|
||||
'fail',
|
||||
error(new InternalServerError(err.message)),
|
||||
'Unexpected error occurred',
|
||||
ERROR.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTask(req, res) {
|
||||
try {
|
||||
const validatePayload = isValidPayload(req.body, updateTaskModel);
|
||||
|
||||
if (validatePayload.err) {
|
||||
return response(
|
||||
res,
|
||||
'fail',
|
||||
{ err: validatePayload.err, data: null },
|
||||
'Invalid task data',
|
||||
ERROR.EXPECTATION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActor(req);
|
||||
const result = await taskService.updateTask(req.params.id, validatePayload.data, actor);
|
||||
|
||||
if (result.err) {
|
||||
return response(res, 'fail', result);
|
||||
}
|
||||
|
||||
return response(res, 'success', result, 'Task updated successfully', SUCCESS.OK);
|
||||
} catch (err) {
|
||||
return response(
|
||||
res,
|
||||
'fail',
|
||||
error(new InternalServerError(err.message)),
|
||||
'Unexpected error occurred',
|
||||
ERROR.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTask(req, res) {
|
||||
try {
|
||||
const actor = getActor(req);
|
||||
const result = await taskService.deleteTask(req.params.id, actor);
|
||||
|
||||
if (result.err) {
|
||||
return response(res, 'fail', result);
|
||||
}
|
||||
|
||||
return response(res, 'success', result, 'Task deleted successfully', SUCCESS.OK);
|
||||
} catch (err) {
|
||||
return response(
|
||||
res,
|
||||
'fail',
|
||||
error(new InternalServerError(err.message)),
|
||||
'Unexpected error occurred',
|
||||
ERROR.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async submitTask(req, res) {
|
||||
try {
|
||||
const validatePayload = isValidPayload(req.body, submitTaskModel);
|
||||
|
||||
if (validatePayload.err) {
|
||||
cleanupUploadedFile(req.file);
|
||||
|
||||
return response(
|
||||
res,
|
||||
'fail',
|
||||
{ err: validatePayload.err, data: null },
|
||||
'Invalid submission data',
|
||||
ERROR.EXPECTATION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActor(req);
|
||||
const result = await taskService.submitTask(
|
||||
req.params.id,
|
||||
validatePayload.data,
|
||||
req.file,
|
||||
actor
|
||||
);
|
||||
|
||||
if (result.err) {
|
||||
cleanupUploadedFile(req.file);
|
||||
return response(res, 'fail', result);
|
||||
}
|
||||
|
||||
return response(res, 'success', result, 'Task submitted successfully', SUCCESS.OK);
|
||||
} catch (err) {
|
||||
cleanupUploadedFile(req.file);
|
||||
|
||||
return response(
|
||||
res,
|
||||
'fail',
|
||||
error(new InternalServerError(err.message)),
|
||||
'Unexpected error occurred',
|
||||
ERROR.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TaskController();
|
||||
@@ -0,0 +1,95 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const baseUploadDir = process.env.NODE_ENV === 'production'
|
||||
? '/tmp/uploads'
|
||||
: path.join(__dirname, '../../uploads');
|
||||
|
||||
const taskUploadDir = path.join(baseUploadDir, 'task-submissions');
|
||||
|
||||
if (!fs.existsSync(taskUploadDir)) {
|
||||
fs.mkdirSync(taskUploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, taskUploadDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + path.extname(file.originalname));
|
||||
},
|
||||
});
|
||||
|
||||
const allowedMimeTypes = [
|
||||
'application/pdf',
|
||||
// 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
// 'application/zip',
|
||||
// 'application/x-zip-compressed',
|
||||
];
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// const errMsg = 'Only PDF, DOCX, or ZIP files are allowed';
|
||||
const errMsg = 'Only PDF files are allowed';
|
||||
req.fileValidationError = errMsg;
|
||||
cb(new Error(errMsg), false);
|
||||
};
|
||||
|
||||
const taskUpload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 2 * 1024 * 1024, //2MB
|
||||
},
|
||||
});
|
||||
|
||||
const taskUploadErrorHandler = (err, _req, res, next) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({
|
||||
status: false,
|
||||
data: null,
|
||||
message: 'File size is too large. Maximum size is 2MB',
|
||||
code: 413,
|
||||
});
|
||||
}
|
||||
|
||||
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
return res.status(400).json({
|
||||
status: false,
|
||||
data: null,
|
||||
message: {
|
||||
[err.field]: `Field '${err.field}' cannot have more than 1 file.`,
|
||||
},
|
||||
code: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(400).json({
|
||||
status: false,
|
||||
data: null,
|
||||
message: err.message,
|
||||
code: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return res.status(400).json({
|
||||
status: false,
|
||||
data: null,
|
||||
message: err.message || 'Error while uploading file',
|
||||
code: 400,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
taskUpload,
|
||||
taskUploadErrorHandler,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const taskController = require('./controllers/task.controller');
|
||||
|
||||
module.exports = {
|
||||
taskController
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const joi = require('joi');
|
||||
|
||||
const SUBMISSION_TYPE_VALUES = ['file_upload', 'url_link'];
|
||||
|
||||
const createTaskModel = joi.object().keys({
|
||||
title: joi.string().max(255).required().messages({
|
||||
'string.base': 'Task title must be a string.',
|
||||
'string.empty': 'Task title cannot be empty.',
|
||||
'string.max': 'Task title must not exceed {#limit} characters.',
|
||||
'any.required': 'Task title is required.',
|
||||
}),
|
||||
|
||||
description: joi.string().required().messages({
|
||||
'string.base': 'Task description must be a string.',
|
||||
'string.empty': 'Task description cannot be empty.',
|
||||
'any.required': 'Task description is required.',
|
||||
}),
|
||||
|
||||
deadline_date: joi.date().required().messages({
|
||||
'date.base': 'Deadline date must be a valid date.',
|
||||
'any.required': 'Deadline date is required.',
|
||||
}),
|
||||
|
||||
specific_time: joi.string().pattern(/^([01]\d|2[0-3]):[0-5]\d$/).required().messages({
|
||||
'string.base': 'Specific time must be a string.',
|
||||
'string.empty': 'Specific time cannot be empty.',
|
||||
'string.pattern.base': 'Specific time must use HH:mm format.',
|
||||
'any.required': 'Specific time is required.',
|
||||
}),
|
||||
|
||||
submission_type: joi.string().valid(...SUBMISSION_TYPE_VALUES).required().messages({
|
||||
'string.base': 'Submission type must be a string.',
|
||||
'any.only': `Submission type must be one of: ${SUBMISSION_TYPE_VALUES.join(', ')}.`,
|
||||
'any.required': 'Submission type is required.',
|
||||
}),
|
||||
});
|
||||
|
||||
const updateTaskModel = joi.object().keys({
|
||||
title: joi.string().max(255).optional().messages({
|
||||
'string.base': 'Task title must be a string.',
|
||||
'string.max': 'Task title must not exceed {#limit} characters.',
|
||||
}),
|
||||
|
||||
description: joi.string().optional().messages({
|
||||
'string.base': 'Task description must be a string.',
|
||||
}),
|
||||
|
||||
deadline_date: joi.date().optional().messages({
|
||||
'date.base': 'Deadline date must be a valid date.',
|
||||
}),
|
||||
|
||||
specific_time: joi.string().pattern(/^([01]\d|2[0-3]):[0-5]\d$/).optional().messages({
|
||||
'string.base': 'Specific time must be a string.',
|
||||
'string.pattern.base': 'Specific time must use HH:mm format.',
|
||||
}),
|
||||
|
||||
submission_type: joi.string().valid(...SUBMISSION_TYPE_VALUES).optional().messages({
|
||||
'string.base': 'Submission type must be a string.',
|
||||
'any.only': `Submission type must be one of: ${SUBMISSION_TYPE_VALUES.join(', ')}.`,
|
||||
}),
|
||||
});
|
||||
|
||||
const submitTaskModel = joi.object().keys({
|
||||
url_link: joi.string().uri().optional().messages({
|
||||
'string.base': 'URL link must be a string.',
|
||||
'string.uri': 'URL link must be a valid URL.',
|
||||
}),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createTaskModel,
|
||||
updateTaskModel,
|
||||
submitTaskModel,
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
const prisma = require('../../../helpers/db/db_connection');
|
||||
|
||||
class TaskRepository {
|
||||
async findProjectById(idProject) {
|
||||
return prisma.project.findUnique({
|
||||
where: {
|
||||
id: parseInt(idProject),
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
status: 'active',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
id_user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createTask(taskData) {
|
||||
return prisma.task.create({
|
||||
data: taskData,
|
||||
});
|
||||
}
|
||||
|
||||
async findTasksByProject(idProject) {
|
||||
return prisma.task.findMany({
|
||||
where: {
|
||||
id_project: parseInt(idProject),
|
||||
},
|
||||
orderBy: {
|
||||
deadline_at: 'asc',
|
||||
},
|
||||
include: {
|
||||
submissions: {
|
||||
select: {
|
||||
id: true,
|
||||
id_task: true,
|
||||
id_user: true,
|
||||
file_path: true,
|
||||
url_link: true,
|
||||
submitted_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findTaskById(idTask) {
|
||||
return prisma.task.findUnique({
|
||||
where: {
|
||||
id: parseInt(idTask),
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
project_name: true,
|
||||
project_icon: true,
|
||||
members: {
|
||||
where: {
|
||||
status: 'active',
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
full_name: true,
|
||||
email: true,
|
||||
professional_bio: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
submissions: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
full_name: true,
|
||||
email: true,
|
||||
professional_bio: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateTask(idTask, taskData) {
|
||||
return prisma.task.update({
|
||||
where: {
|
||||
id: parseInt(idTask),
|
||||
},
|
||||
data: taskData,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTask(idTask) {
|
||||
return prisma.task.delete({
|
||||
where: {
|
||||
id: parseInt(idTask),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async countSubmissionsByTask(idTask) {
|
||||
return prisma.taskSubmission.count({
|
||||
where: {
|
||||
id_task: parseInt(idTask),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async checkActiveProjectMember(idProject, idUser) {
|
||||
return prisma.projectMember.findFirst({
|
||||
where: {
|
||||
id_project: parseInt(idProject),
|
||||
id_user: parseInt(idUser),
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findSubmissionByTaskAndUser(idTask, idUser) {
|
||||
return prisma.taskSubmission.findUnique({
|
||||
where: {
|
||||
id_task_id_user: {
|
||||
id_task: parseInt(idTask),
|
||||
id_user: parseInt(idUser),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createSubmission(submissionData) {
|
||||
return prisma.taskSubmission.create({
|
||||
data: submissionData,
|
||||
});
|
||||
}
|
||||
|
||||
async updateSubmission(idSubmission, submissionData) {
|
||||
return prisma.taskSubmission.update({
|
||||
where: {
|
||||
id: parseInt(idSubmission),
|
||||
},
|
||||
data: submissionData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TaskRepository();
|
||||
@@ -0,0 +1,441 @@
|
||||
const taskRepository = require('../repositories/task.repository');
|
||||
const { data, error } = require('../../../helpers/utils/wrapper');
|
||||
const {
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
ForbiddenError,
|
||||
ConflictError,
|
||||
} = require('../../../helpers/error');
|
||||
|
||||
class TaskService {
|
||||
async createTask(idProject, payload, actor = {}) {
|
||||
try {
|
||||
if (!this.isPositiveInteger(idProject)) {
|
||||
return error(new BadRequestError('Project ID must be a valid number'));
|
||||
}
|
||||
|
||||
if (!actor?.id || actor.role !== 'admin') {
|
||||
return error(new ForbiddenError('Access denied: only admin can create task'));
|
||||
}
|
||||
|
||||
const project = await taskRepository.findProjectById(idProject);
|
||||
|
||||
if (!project) {
|
||||
return error(new NotFoundError('Project not found'));
|
||||
}
|
||||
|
||||
const deadlineAt = this.buildDeadlineAt(payload.deadline_date, payload.specific_time);
|
||||
|
||||
const task = await taskRepository.createTask({
|
||||
id_project: parseInt(idProject),
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
deadline_at: deadlineAt,
|
||||
submission_type: payload.submission_type,
|
||||
});
|
||||
|
||||
return data(this.mapTask(task));
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async getTasksByProject(idProject, actor = {}) {
|
||||
try {
|
||||
if (!this.isPositiveInteger(idProject)) {
|
||||
return error(new BadRequestError('Project ID must be a valid number'));
|
||||
}
|
||||
|
||||
const project = await taskRepository.findProjectById(idProject);
|
||||
|
||||
if (!project) {
|
||||
return error(new NotFoundError('Project not found'));
|
||||
}
|
||||
|
||||
const accessError = await this.validateProjectAccess(idProject, actor);
|
||||
|
||||
if (accessError) {
|
||||
return error(accessError);
|
||||
}
|
||||
|
||||
const tasks = await taskRepository.findTasksByProject(idProject);
|
||||
const totalMembers = project.members?.length || 0;
|
||||
|
||||
const mappedTasks = tasks.map((task) => {
|
||||
if (actor.role === 'intern') {
|
||||
const mySubmission = task.submissions.find(
|
||||
(submission) => submission.id_user === parseInt(actor.id)
|
||||
) || null;
|
||||
|
||||
return {
|
||||
...this.mapTask(task),
|
||||
display_status: this.getDisplayStatus(task.deadline_at, mySubmission),
|
||||
my_submission: mySubmission ? this.mapSubmission(mySubmission) : null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...this.mapTask(task),
|
||||
total_members: totalMembers,
|
||||
total_submissions: task.submissions?.length || 0,
|
||||
};
|
||||
});
|
||||
|
||||
return data(mappedTasks);
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async getTaskById(idTask, actor = {}) {
|
||||
try {
|
||||
if (!this.isPositiveInteger(idTask)) {
|
||||
return error(new BadRequestError('Task ID must be a valid number'));
|
||||
}
|
||||
|
||||
const task = await taskRepository.findTaskById(idTask);
|
||||
|
||||
if (!task) {
|
||||
return error(new NotFoundError('Task not found'));
|
||||
}
|
||||
|
||||
const accessError = await this.validateProjectAccess(task.id_project, actor);
|
||||
|
||||
if (accessError) {
|
||||
return error(accessError);
|
||||
}
|
||||
|
||||
if (actor.role === 'admin') {
|
||||
return data(this.mapAdminTaskDetail(task));
|
||||
}
|
||||
|
||||
const mySubmission = task.submissions.find(
|
||||
(submission) => submission.id_user === parseInt(actor.id)
|
||||
) || null;
|
||||
|
||||
return data(this.mapInternTaskDetail(task, mySubmission));
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTask(idTask, payload, actor = {}) {
|
||||
try {
|
||||
if (!this.isPositiveInteger(idTask)) {
|
||||
return error(new BadRequestError('Task ID must be a valid number'));
|
||||
}
|
||||
|
||||
if (!actor?.id || actor.role !== 'admin') {
|
||||
return error(new ForbiddenError('Access denied: only admin can update task'));
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return error(new BadRequestError('At least one field is required to update task'));
|
||||
}
|
||||
|
||||
const existingTask = await taskRepository.findTaskById(idTask);
|
||||
|
||||
if (!existingTask) {
|
||||
return error(new NotFoundError('Task not found'));
|
||||
}
|
||||
|
||||
const submissionCount = await taskRepository.countSubmissionsByTask(idTask);
|
||||
|
||||
if (
|
||||
payload.submission_type &&
|
||||
payload.submission_type !== existingTask.submission_type &&
|
||||
submissionCount > 0
|
||||
) {
|
||||
return error(
|
||||
new ConflictError('Submission type cannot be changed because this task already has submissions')
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
|
||||
if (payload.title !== undefined) updateData.title = payload.title;
|
||||
if (payload.description !== undefined) updateData.description = payload.description;
|
||||
if (payload.submission_type !== undefined) updateData.submission_type = payload.submission_type;
|
||||
|
||||
if (payload.deadline_date !== undefined || payload.specific_time !== undefined) {
|
||||
updateData.deadline_at = this.buildDeadlineAtFromExisting(
|
||||
existingTask.deadline_at,
|
||||
payload.deadline_date,
|
||||
payload.specific_time
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTask = await taskRepository.updateTask(idTask, updateData);
|
||||
|
||||
return data(this.mapTask(updatedTask));
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTask(idTask, actor = {}) {
|
||||
try {
|
||||
if (!this.isPositiveInteger(idTask)) {
|
||||
return error(new BadRequestError('Task ID must be a valid number'));
|
||||
}
|
||||
|
||||
if (!actor?.id || actor.role !== 'admin') {
|
||||
return error(new ForbiddenError('Access denied: only admin can delete task'));
|
||||
}
|
||||
|
||||
const task = await taskRepository.findTaskById(idTask);
|
||||
|
||||
if (!task) {
|
||||
return error(new NotFoundError('Task not found'));
|
||||
}
|
||||
|
||||
const submissionCount = await taskRepository.countSubmissionsByTask(idTask);
|
||||
|
||||
if (submissionCount > 0) {
|
||||
return error(new ConflictError('Task already has submissions and cannot be deleted'));
|
||||
}
|
||||
|
||||
await taskRepository.deleteTask(idTask);
|
||||
|
||||
return data(null);
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async submitTask(idTask, payload, file, actor = {}) {
|
||||
try {
|
||||
if (!this.isPositiveInteger(idTask)) {
|
||||
return error(new BadRequestError('Task ID must be a valid number'));
|
||||
}
|
||||
|
||||
if (!actor?.id || actor.role !== 'intern') {
|
||||
return error(new ForbiddenError('Access denied: only intern can submit task'));
|
||||
}
|
||||
|
||||
const task = await taskRepository.findTaskById(idTask);
|
||||
|
||||
if (!task) {
|
||||
return error(new NotFoundError('Task not found'));
|
||||
}
|
||||
|
||||
const membership = await taskRepository.checkActiveProjectMember(task.id_project, actor.id);
|
||||
|
||||
if (!membership) {
|
||||
return error(new ForbiddenError('Access denied: you are not a member of this project'));
|
||||
}
|
||||
|
||||
const submissionData = {
|
||||
id_task: parseInt(idTask),
|
||||
id_user: parseInt(actor.id),
|
||||
};
|
||||
|
||||
if (task.submission_type === 'file_upload') {
|
||||
if (!file) {
|
||||
return error(new BadRequestError('Submission file is required for this task'));
|
||||
}
|
||||
|
||||
if (payload.url_link) {
|
||||
return error(new BadRequestError('URL link is not allowed for file upload task'));
|
||||
}
|
||||
|
||||
submissionData.file_path = `/uploads/task-submissions/${file.filename}`;
|
||||
submissionData.url_link = null;
|
||||
}
|
||||
|
||||
if (task.submission_type === 'url_link') {
|
||||
if (file) {
|
||||
return error(new BadRequestError('File upload is not allowed for URL link task'));
|
||||
}
|
||||
|
||||
if (!payload.url_link) {
|
||||
return error(new BadRequestError('URL link is required for this task'));
|
||||
}
|
||||
|
||||
submissionData.file_path = null;
|
||||
submissionData.url_link = payload.url_link;
|
||||
}
|
||||
|
||||
const existingSubmission = await taskRepository.findSubmissionByTaskAndUser(idTask, actor.id);
|
||||
|
||||
let submission;
|
||||
|
||||
if (existingSubmission) {
|
||||
submission = await taskRepository.updateSubmission(existingSubmission.id, submissionData);
|
||||
} else {
|
||||
submission = await taskRepository.createSubmission(submissionData);
|
||||
}
|
||||
|
||||
return data(this.mapSubmission(submission));
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async validateProjectAccess(idProject, actor = {}) {
|
||||
if (!actor?.id || !actor?.role) {
|
||||
return new ForbiddenError('Access denied: authentication data is missing');
|
||||
}
|
||||
|
||||
if (actor.role === 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (actor.role === 'intern') {
|
||||
const membership = await taskRepository.checkActiveProjectMember(idProject, actor.id);
|
||||
|
||||
if (!membership) {
|
||||
return new ForbiddenError('Access denied: you are not a member of this project');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ForbiddenError('Access denied: invalid role');
|
||||
}
|
||||
|
||||
buildDeadlineAt(deadlineDate, specificTime) {
|
||||
const dateOnly = this.formatDateOnly(new Date(deadlineDate));
|
||||
return new Date(`${dateOnly}T${specificTime}:00`);
|
||||
}
|
||||
|
||||
buildDeadlineAtFromExisting(existingDeadlineAt, deadlineDate, specificTime) {
|
||||
const currentDate = this.formatDateOnly(new Date(existingDeadlineAt));
|
||||
const currentTime = this.formatTimeOnly(new Date(existingDeadlineAt));
|
||||
|
||||
const nextDate = deadlineDate !== undefined
|
||||
? this.formatDateOnly(new Date(deadlineDate))
|
||||
: currentDate;
|
||||
|
||||
const nextTime = specificTime !== undefined
|
||||
? specificTime
|
||||
: currentTime;
|
||||
|
||||
return new Date(`${nextDate}T${nextTime}:00`);
|
||||
}
|
||||
|
||||
formatDateOnly(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
formatTimeOnly(date) {
|
||||
const hours = `${date.getHours()}`.padStart(2, '0');
|
||||
const minutes = `${date.getMinutes()}`.padStart(2, '0');
|
||||
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
getDisplayStatus(deadlineAt, submission) {
|
||||
const now = new Date();
|
||||
const deadline = new Date(deadlineAt);
|
||||
|
||||
if (deadline < now) {
|
||||
return 'overdue';
|
||||
}
|
||||
|
||||
if (submission) {
|
||||
return 'done';
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
mapTask(task) {
|
||||
if (!task) return null;
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
id_project: task.id_project,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
deadline_at: task.deadline_at,
|
||||
submission_type: task.submission_type,
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
mapSubmission(submission) {
|
||||
if (!submission) return null;
|
||||
|
||||
return {
|
||||
id: submission.id,
|
||||
id_task: submission.id_task,
|
||||
id_user: submission.id_user,
|
||||
file_path: submission.file_path,
|
||||
url_link: submission.url_link,
|
||||
submitted_at: submission.submitted_at,
|
||||
updated_at: submission.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
mapInternTaskDetail(task, submission) {
|
||||
return {
|
||||
...this.mapTask(task),
|
||||
project: {
|
||||
id: task.project?.id,
|
||||
project_name: task.project?.project_name,
|
||||
project_icon: task.project?.project_icon,
|
||||
},
|
||||
display_status: this.getDisplayStatus(task.deadline_at, submission),
|
||||
my_submission: submission ? this.mapSubmission(submission) : null,
|
||||
};
|
||||
}
|
||||
|
||||
mapAdminTaskDetail(task) {
|
||||
const members = task.project?.members || [];
|
||||
const submissions = task.submissions || [];
|
||||
|
||||
const mappedSubmissions = members.map((member) => {
|
||||
const user = member.user;
|
||||
const submission = submissions.find(
|
||||
(item) => item.id_user === user.id
|
||||
) || null;
|
||||
|
||||
const displayStatus = this.getDisplayStatus(task.deadline_at, submission);
|
||||
|
||||
return {
|
||||
id_user: user.id,
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
profile_picture: null,
|
||||
professional_bio: user.professional_bio || null,
|
||||
submitted_at: submission?.submitted_at || null,
|
||||
display_status: displayStatus,
|
||||
submission: submission ? this.mapSubmission(submission) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const totalDone = mappedSubmissions.filter((item) => item.display_status === 'done').length;
|
||||
const totalPending = mappedSubmissions.filter((item) => item.display_status === 'pending').length;
|
||||
const totalOverdue = mappedSubmissions.filter((item) => item.display_status === 'overdue').length;
|
||||
|
||||
return {
|
||||
...this.mapTask(task),
|
||||
project: {
|
||||
id: task.project?.id,
|
||||
project_name: task.project?.project_name,
|
||||
project_icon: task.project?.project_icon,
|
||||
},
|
||||
submission_summary: {
|
||||
total_members: members.length,
|
||||
total_submitted: submissions.length,
|
||||
total_done: totalDone,
|
||||
total_pending: totalPending,
|
||||
total_overdue: totalOverdue,
|
||||
},
|
||||
submissions: mappedSubmissions,
|
||||
};
|
||||
}
|
||||
|
||||
isPositiveInteger(value) {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) && number > 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TaskService();
|
||||
Reference in New Issue
Block a user