diff --git a/src/docs/swagger.js b/src/docs/swagger.js index dc35ebc..f2815e1 100755 --- a/src/docs/swagger.js +++ b/src/docs/swagger.js @@ -67,6 +67,10 @@ const swaggerOptions = { { name: "Project", description: "Internify LMS project management endpoints" + }, + { + name: "Task", + description: "Managing project tasks and intern submissions" } ], components: { diff --git a/src/index.js b/src/index.js index 7831774..0431f2b 100755 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ const faqRoutes = require('./routes/faq.routes'); const feedbackRoutes = require('./routes/feedback.routes'); const batchRoutes = require('./routes/batch.routes'); const projectRoutes = require('./routes/project.routes'); +const taskRoutes = require('./routes/task.routes'); const setupSwaggerDocs = require('./docs/swagger'); const PORT = process.env.PORT; @@ -56,8 +57,8 @@ app.use("/faq-api", faqRoutes); app.use("/feedback-api", feedbackRoutes); app.use("/batch-api", batchRoutes); app.use("/project-api", projectRoutes); +app.use("/task-api", taskRoutes); // app.use("/project-member-api", projectMemberRoutes); -// app.use("/task-api", taskRoutes); setupSwaggerDocs(app); diff --git a/src/modules/task/controllers/task.controller.js b/src/modules/task/controllers/task.controller.js new file mode 100644 index 0000000..712568a --- /dev/null +++ b/src/modules/task/controllers/task.controller.js @@ -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(); diff --git a/src/modules/task/helpers/taskUpload.helper.js b/src/modules/task/helpers/taskUpload.helper.js new file mode 100644 index 0000000..97b559d --- /dev/null +++ b/src/modules/task/helpers/taskUpload.helper.js @@ -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, +}; diff --git a/src/modules/task/index.js b/src/modules/task/index.js index e69de29..e58623e 100644 --- a/src/modules/task/index.js +++ b/src/modules/task/index.js @@ -0,0 +1,5 @@ +const taskController = require('./controllers/task.controller'); + +module.exports = { + taskController +}; diff --git a/src/modules/task/models/task.model.js b/src/modules/task/models/task.model.js new file mode 100644 index 0000000..9961d12 --- /dev/null +++ b/src/modules/task/models/task.model.js @@ -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, +}; diff --git a/src/modules/task/repositories/task.repository.js b/src/modules/task/repositories/task.repository.js new file mode 100644 index 0000000..651994a --- /dev/null +++ b/src/modules/task/repositories/task.repository.js @@ -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(); diff --git a/src/modules/task/services/task.service.js b/src/modules/task/services/task.service.js new file mode 100644 index 0000000..de9f41a --- /dev/null +++ b/src/modules/task/services/task.service.js @@ -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(); diff --git a/src/routes/task.routes.js b/src/routes/task.routes.js index e69de29..3f8abc7 100644 --- a/src/routes/task.routes.js +++ b/src/routes/task.routes.js @@ -0,0 +1,597 @@ +const express = require('express'); +const { taskController } = require('../modules/task'); +const { verifyJWT } = require('../middleware/verifyJWT'); +const isAdmin = require('../middleware/isAdmin'); +const { + taskUpload, + taskUploadErrorHandler, +} = require('../modules/task/helpers/taskUpload.helper'); + +const router = express.Router(); + +/** + * @swagger + * components: + * schemas: + * Task: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * id_project: + * type: integer + * example: 1 + * title: + * type: string + * example: Laporan Tugas 2 + * description: + * type: string + * example: Berisi design UI/UX dan user flow dari aplikasi Internify. + * deadline_at: + * type: string + * format: date-time + * example: 2026-05-28T23:59:00.000Z + * submission_type: + * type: string + * enum: [file_upload, url_link] + * example: file_upload + * created_at: + * type: string + * format: date-time + * example: 2026-06-09T10:00:00.000Z + * updated_at: + * type: string + * format: date-time + * example: 2026-06-09T10:00:00.000Z + * + * TaskCreateRequest: + * type: object + * required: + * - title + * - description + * - deadline_date + * - specific_time + * - submission_type + * properties: + * title: + * type: string + * example: Laporan Tugas 2 + * description: + * type: string + * example: Berisi design UI/UX dan user flow dari aplikasi Internify. + * deadline_date: + * type: string + * format: date + * example: 2026-05-28 + * specific_time: + * type: string + * example: "23:59" + * submission_type: + * type: string + * enum: [file_upload, url_link] + * example: file_upload + * + * TaskUpdateRequest: + * type: object + * properties: + * title: + * type: string + * example: Laporan Tugas 2 Updated + * description: + * type: string + * example: Updated task description. + * deadline_date: + * type: string + * format: date + * example: 2026-05-30 + * specific_time: + * type: string + * example: "23:59" + * submission_type: + * type: string + * enum: [file_upload, url_link] + * example: url_link + * + * TaskSubmission: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * id_task: + * type: integer + * example: 1 + * id_user: + * type: integer + * example: 2 + * file_path: + * type: string + * nullable: true + * example: /uploads/task-submissions/report.pdf + * url_link: + * type: string + * nullable: true + * example: https://drive.google.com/file/example + * submitted_at: + * type: string + * format: date-time + * example: 2026-06-09T10:00:00.000Z + * updated_at: + * type: string + * format: date-time + * example: 2026-06-09T10:00:00.000Z + * + * InternTaskItem: + * allOf: + * - $ref: '#/components/schemas/Task' + * - type: object + * properties: + * display_status: + * type: string + * enum: [pending, done, overdue] + * example: pending + * my_submission: + * nullable: true + * $ref: '#/components/schemas/TaskSubmission' + * + * AdminTaskItem: + * allOf: + * - $ref: '#/components/schemas/Task' + * - type: object + * properties: + * total_members: + * type: integer + * example: 8 + * total_submissions: + * type: integer + * example: 5 + * + * AdminTaskSubmissionItem: + * type: object + * properties: + * id_user: + * type: integer + * example: 1 + * full_name: + * type: string + * example: Alex Rivera + * email: + * type: string + * example: alex.rivera@example.com + * profile_picture: + * type: string + * nullable: true + * example: null + * professional_bio: + * type: string + * nullable: true + * example: Backend developer intern + * submitted_at: + * type: string + * nullable: true + * format: date-time + * example: 2026-05-25T10:00:00.000Z + * display_status: + * type: string + * enum: [pending, done, overdue] + * example: done + * submission: + * nullable: true + * $ref: '#/components/schemas/TaskSubmission' + * + * AdminTaskDetail: + * allOf: + * - $ref: '#/components/schemas/Task' + * - type: object + * properties: + * project: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * project_name: + * type: string + * example: Internify Project + * project_icon: + * type: string + * example: code + * submission_summary: + * type: object + * properties: + * total_members: + * type: integer + * example: 8 + * total_submitted: + * type: integer + * example: 5 + * total_done: + * type: integer + * example: 4 + * total_pending: + * type: integer + * example: 3 + * total_overdue: + * type: integer + * example: 1 + * submissions: + * type: array + * items: + * $ref: '#/components/schemas/AdminTaskSubmissionItem' + * + * InternTaskDetail: + * allOf: + * - $ref: '#/components/schemas/Task' + * - type: object + * properties: + * project: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * project_name: + * type: string + * example: Internify Project + * project_icon: + * type: string + * example: code + * display_status: + * type: string + * enum: [pending, done, overdue] + * example: pending + * my_submission: + * nullable: true + * $ref: '#/components/schemas/TaskSubmission' + * + * TaskResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/Task' + * message: + * type: string + * example: Task created successfully + * code: + * type: integer + * example: 201 + * + * TaskListResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: array + * items: + * $ref: '#/components/schemas/Task' + * message: + * type: string + * example: Tasks retrieved successfully + * code: + * type: integer + * example: 200 + * + * AdminTaskDetailResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/AdminTaskDetail' + * message: + * type: string + * example: Task detail retrieved successfully + * code: + * type: integer + * example: 200 + * + * InternTaskDetailResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/InternTaskDetail' + * message: + * type: string + * example: Task detail retrieved successfully + * code: + * type: integer + * example: 200 + * + * TaskSubmissionResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/TaskSubmission' + * message: + * type: string + * example: Task submitted successfully + * code: + * type: integer + * example: 200 + * + * ErrorResponse: + * type: object + * properties: + * status: + * type: boolean + * example: false + * data: + * nullable: true + * example: null + * message: + * type: string + * example: Validation error + * code: + * type: integer + * example: 417 + */ + +/** + * @swagger + * /task-api/projects/{id_project}/tasks: + * post: + * summary: Create task in project + * description: Create a new task inside a project. Only admin can access this endpoint. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id_project + * required: true + * schema: + * type: integer + * description: Project ID + * example: 1 + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskCreateRequest' + * responses: + * 201: + * description: Task created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskResponse' + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Project not found + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.post('/projects/:id_project/tasks', verifyJWT, isAdmin, taskController.createTask); + +/** + * @swagger + * /task-api/projects/{id_project}/tasks: + * get: + * summary: Get project tasks + * description: Get all tasks from a project. Admin can access any project task list. Intern can only access tasks from their active project. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id_project + * required: true + * schema: + * type: integer + * description: Project ID + * example: 1 + * responses: + * 200: + * description: Tasks retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskListResponse' + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Project not found + * 500: + * description: Internal server error + */ +router.get('/projects/:id_project/tasks', verifyJWT, taskController.getTasksByProject); + +/** + * @swagger + * /task-api/tasks/{id}: + * get: + * summary: Get task detail + * description: Get task detail. Admin receives all intern submissions. Intern receives only their own submission. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Task ID + * example: 1 + * responses: + * 200: + * description: Task detail retrieved successfully + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Task not found + * 500: + * description: Internal server error + */ +router.get('/tasks/:id', verifyJWT, taskController.getTaskById); + +/** + * @swagger + * /task-api/tasks/{id}: + * patch: + * summary: Update task + * description: Update task information. Submission type cannot be changed if the task already has submissions. Only admin can access this endpoint. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Task ID + * example: 1 + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskUpdateRequest' + * responses: + * 200: + * description: Task updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskResponse' + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Task not found + * 409: + * description: Submission type cannot be changed because this task already has submissions + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.patch('/tasks/:id', verifyJWT, isAdmin, taskController.updateTask); + +/** + * @swagger + * /task-api/tasks/{id}: + * delete: + * summary: Delete task + * description: Delete task only if it does not have submissions. Only admin can access this endpoint. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Task ID + * example: 1 + * responses: + * 200: + * description: Task deleted successfully + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Task not found + * 409: + * description: Task already has submissions and cannot be deleted + * 500: + * description: Internal server error + */ +router.delete('/tasks/:id', verifyJWT, isAdmin, taskController.deleteTask); + +/** + * @swagger + * /task-api/tasks/{id}/submissions: + * post: + * summary: Submit task + * description: Submit a task as an intern. For file_upload tasks, send submission_file. For url_link tasks, send url_link. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Task ID + * example: 1 + * requestBody: + * required: false + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * submission_file: + * type: string + * format: binary + * description: Required when task submission_type is file_upload. Allowed file types are PDF. Max size is 10MB. + * url_link: + * type: string + * format: uri + * description: Required when task submission_type is url_link. + * application/json: + * schema: + * type: object + * properties: + * url_link: + * type: string + * format: uri + * example: https://drive.google.com/file/example + * responses: + * 200: + * description: Task submitted successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskSubmissionResponse' + * 400: + * description: Bad request + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Task not found + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.post( + '/tasks/:id/submissions', + verifyJWT, + taskUpload.single('submission_file'), + taskUploadErrorHandler, + taskController.submitTask +); + +module.exports = router;