diff --git a/src/modules/task/controllers/task.controller.js b/src/modules/task/controllers/task.controller.js index 712568a..58e04a8 100644 --- a/src/modules/task/controllers/task.controller.js +++ b/src/modules/task/controllers/task.controller.js @@ -8,6 +8,7 @@ const { createTaskModel, updateTaskModel, submitTaskModel, + updateSubmissionModel, } = require('../models/task.model'); const getActor = (req) => ({ @@ -198,6 +199,70 @@ class TaskController { ); } } + + async updateSubmission(req, res) { + try { + const validatePayload = isValidPayload(req.body, updateSubmissionModel); + + 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.updateSubmission( + req.params.id, + validatePayload.data, + req.file, + actor + ); + + if (result.err) { + cleanupUploadedFile(req.file); + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Submission updated successfully', SUCCESS.OK); + } catch (err) { + cleanupUploadedFile(req.file); + + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async deleteSubmission(req, res) { + try { + const actor = getActor(req); + const result = await taskService.deleteSubmission(req.params.id, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Submission deleted successfully', SUCCESS.OK); + } catch (err) { + 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 index 97b559d..4f6468b 100644 --- a/src/modules/task/helpers/taskUpload.helper.js +++ b/src/modules/task/helpers/taskUpload.helper.js @@ -4,7 +4,7 @@ const fs = require('fs'); const baseUploadDir = process.env.NODE_ENV === 'production' ? '/tmp/uploads' - : path.join(__dirname, '../../uploads'); + : path.join(__dirname, '../../../uploads'); const taskUploadDir = path.join(baseUploadDir, 'task-submissions'); @@ -43,7 +43,7 @@ const taskUpload = multer({ storage, fileFilter, limits: { - fileSize: 2 * 1024 * 1024, //2MB + fileSize: 10 * 1024 * 1024, //10MB }, }); @@ -53,7 +53,7 @@ const taskUploadErrorHandler = (err, _req, res, next) => { return res.status(413).json({ status: false, data: null, - message: 'File size is too large. Maximum size is 2MB', + message: 'File size is too large. Maximum size is 10MB', code: 413, }); } @@ -92,4 +92,5 @@ const taskUploadErrorHandler = (err, _req, res, next) => { module.exports = { taskUpload, taskUploadErrorHandler, + taskUploadDir }; diff --git a/src/modules/task/models/task.model.js b/src/modules/task/models/task.model.js index 9961d12..9d51588 100644 --- a/src/modules/task/models/task.model.js +++ b/src/modules/task/models/task.model.js @@ -67,8 +67,16 @@ const submitTaskModel = joi.object().keys({ }), }); +const updateSubmissionModel = 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, + updateSubmissionModel, }; diff --git a/src/modules/task/repositories/task.repository.js b/src/modules/task/repositories/task.repository.js index 651994a..28e5d24 100644 --- a/src/modules/task/repositories/task.repository.js +++ b/src/modules/task/repositories/task.repository.js @@ -140,6 +140,38 @@ class TaskRepository { }); } + async findSubmissionById(idSubmission) { + return prisma.taskSubmission.findUnique({ + where: { + id: parseInt(idSubmission), + }, + include: { + task: { + select: { + id: true, + id_project: true, + title: true, + submission_type: true, + deadline_at: true, + project: { + select: { + id: true, + project_name: true, + }, + }, + }, + }, + user: { + select: { + id: true, + full_name: true, + email: true, + }, + }, + }, + }); + } + async createSubmission(submissionData) { return prisma.taskSubmission.create({ data: submissionData, @@ -154,6 +186,14 @@ class TaskRepository { data: submissionData, }); } + + async deleteSubmission(idSubmission) { + return prisma.taskSubmission.delete({ + where: { + id: parseInt(idSubmission), + }, + }); + } } module.exports = new TaskRepository(); diff --git a/src/modules/task/services/task.service.js b/src/modules/task/services/task.service.js index de9f41a..a92e3ab 100644 --- a/src/modules/task/services/task.service.js +++ b/src/modules/task/services/task.service.js @@ -1,4 +1,7 @@ +const fs = require('fs'); +const path = require('path'); const taskRepository = require('../repositories/task.repository'); +const { taskUploadDir } = require('../helpers/taskUpload.helper'); const { data, error } = require('../../../helpers/utils/wrapper'); const { BadRequestError, @@ -261,6 +264,7 @@ class TaskService { let submission; if (existingSubmission) { + this.deleteLocalSubmissionFile(existingSubmission.file_path); submission = await taskRepository.updateSubmission(existingSubmission.id, submissionData); } else { submission = await taskRepository.createSubmission(submissionData); @@ -272,6 +276,115 @@ class TaskService { } } + async updateSubmission(idSubmission, payload, file, actor = {}) { + try { + if (!this.isPositiveInteger(idSubmission)) { + return error(new BadRequestError('Submission ID must be a valid number')); + } + + if (!actor?.id || actor.role !== 'intern') { + return error(new ForbiddenError('Access denied: only intern can update submission')); + } + + const submission = await taskRepository.findSubmissionById(idSubmission); + + if (!submission) { + return error(new NotFoundError('Submission not found')); + } + + if (submission.id_user !== parseInt(actor.id)) { + return error(new ForbiddenError('Access denied: you can only update your own submission')); + } + + const membership = await taskRepository.checkActiveProjectMember( + submission.task.id_project, + actor.id + ); + + if (!membership) { + return error(new ForbiddenError('Access denied: you are not a member of this project')); + } + + const updateData = {}; + + if (submission.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')); + } + + updateData.file_path = `/uploads/task-submissions/${file.filename}`; + updateData.url_link = null; + } + + if (submission.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')); + } + + updateData.file_path = null; + updateData.url_link = payload.url_link; + } + + this.deleteLocalSubmissionFile(submission.file_path); + + const updatedSubmission = await taskRepository.updateSubmission( + idSubmission, + updateData + ); + + return data(this.mapSubmission(updatedSubmission)); + } catch (err) { + return error(err); + } + } + + async deleteSubmission(idSubmission, actor = {}) { + try { + if (!this.isPositiveInteger(idSubmission)) { + return error(new BadRequestError('Submission ID must be a valid number')); + } + + if (!actor?.id || actor.role !== 'intern') { + return error(new ForbiddenError('Access denied: only intern can delete submission')); + } + + const submission = await taskRepository.findSubmissionById(idSubmission); + + if (!submission) { + return error(new NotFoundError('Submission not found')); + } + + if (submission.id_user !== parseInt(actor.id)) { + return error(new ForbiddenError('Access denied: you can only delete your own submission')); + } + + const membership = await taskRepository.checkActiveProjectMember( + submission.task.id_project, + actor.id + ); + + if (!membership) { + return error(new ForbiddenError('Access denied: you are not a member of this project')); + } + + await taskRepository.deleteSubmission(idSubmission); + + this.deleteLocalSubmissionFile(submission.file_path); + + return data(null); + } catch (err) { + return error(err); + } + } + async validateProjectAccess(idProject, actor = {}) { if (!actor?.id || !actor?.role) { return new ForbiddenError('Access denied: authentication data is missing'); @@ -432,6 +545,27 @@ class TaskService { }; } + deleteLocalSubmissionFile(filePath) { + if (!filePath || typeof filePath !== 'string') { + return; + } + + if (!filePath.startsWith('/uploads/task-submissions/')) { + return; + } + + if (!taskUploadDir || typeof taskUploadDir !== 'string') { + return; + } + + const filename = path.basename(filePath); + const absolutePath = path.join(taskUploadDir, filename); + + if (fs.existsSync(absolutePath)) { + fs.unlinkSync(absolutePath); + } + } + isPositiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0; diff --git a/src/routes/task.routes.js b/src/routes/task.routes.js index 3f8abc7..ed84943 100644 --- a/src/routes/task.routes.js +++ b/src/routes/task.routes.js @@ -594,4 +594,124 @@ router.post( taskController.submitTask ); +/** + * @swagger + * /task-api/submissions/{id}: + * patch: + * summary: Update submission + * description: Update an intern submission. Only the owner intern can update their own submission. 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: Submission ID + * example: 1 + * requestBody: + * required: false + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * submission_file: + * type: string + * format: binary + * description: Required when related task submission_type is file_upload. Allowed file types are PDF, DOCX, and ZIP. Max size is 10MB. + * url_link: + * type: string + * format: uri + * description: Required when related 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-updated + * responses: + * 200: + * description: Submission updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskSubmissionResponse' + * 400: + * description: Bad request + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Submission not found + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.patch( + '/submissions/:id', + verifyJWT, + taskUpload.single('submission_file'), + taskUploadErrorHandler, + taskController.updateSubmission +); + +/** + * @swagger + * /task-api/submissions/{id}: + * delete: + * summary: Delete submission + * description: Delete an intern submission. Only the owner intern can delete their own submission. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Submission ID + * example: 1 + * responses: + * 200: + * description: Submission deleted successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * nullable: true + * example: null + * message: + * type: string + * example: Submission deleted successfully + * code: + * type: integer + * example: 200 + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Submission not found + * 500: + * description: Internal server error + */ +router.delete( + '/submissions/:id', + verifyJWT, + taskController.deleteSubmission +); + module.exports = router;