feat: menambahkan API update dan delete submission

This commit is contained in:
Muhammad Zhafran Ilham
2026-06-10 02:56:24 +07:00
parent 25f7d23690
commit 662e9abc51
6 changed files with 371 additions and 3 deletions
@@ -8,6 +8,7 @@ const {
createTaskModel, createTaskModel,
updateTaskModel, updateTaskModel,
submitTaskModel, submitTaskModel,
updateSubmissionModel,
} = require('../models/task.model'); } = require('../models/task.model');
const getActor = (req) => ({ 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(); module.exports = new TaskController();
@@ -4,7 +4,7 @@ const fs = require('fs');
const baseUploadDir = process.env.NODE_ENV === 'production' const baseUploadDir = process.env.NODE_ENV === 'production'
? '/tmp/uploads' ? '/tmp/uploads'
: path.join(__dirname, '../../uploads'); : path.join(__dirname, '../../../uploads');
const taskUploadDir = path.join(baseUploadDir, 'task-submissions'); const taskUploadDir = path.join(baseUploadDir, 'task-submissions');
@@ -43,7 +43,7 @@ const taskUpload = multer({
storage, storage,
fileFilter, fileFilter,
limits: { 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({ return res.status(413).json({
status: false, status: false,
data: null, data: null,
message: 'File size is too large. Maximum size is 2MB', message: 'File size is too large. Maximum size is 10MB',
code: 413, code: 413,
}); });
} }
@@ -92,4 +92,5 @@ const taskUploadErrorHandler = (err, _req, res, next) => {
module.exports = { module.exports = {
taskUpload, taskUpload,
taskUploadErrorHandler, taskUploadErrorHandler,
taskUploadDir
}; };
+8
View File
@@ -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 = { module.exports = {
createTaskModel, createTaskModel,
updateTaskModel, updateTaskModel,
submitTaskModel, submitTaskModel,
updateSubmissionModel,
}; };
@@ -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) { async createSubmission(submissionData) {
return prisma.taskSubmission.create({ return prisma.taskSubmission.create({
data: submissionData, data: submissionData,
@@ -154,6 +186,14 @@ class TaskRepository {
data: submissionData, data: submissionData,
}); });
} }
async deleteSubmission(idSubmission) {
return prisma.taskSubmission.delete({
where: {
id: parseInt(idSubmission),
},
});
}
} }
module.exports = new TaskRepository(); module.exports = new TaskRepository();
+134
View File
@@ -1,4 +1,7 @@
const fs = require('fs');
const path = require('path');
const taskRepository = require('../repositories/task.repository'); const taskRepository = require('../repositories/task.repository');
const { taskUploadDir } = require('../helpers/taskUpload.helper');
const { data, error } = require('../../../helpers/utils/wrapper'); const { data, error } = require('../../../helpers/utils/wrapper');
const { const {
BadRequestError, BadRequestError,
@@ -261,6 +264,7 @@ class TaskService {
let submission; let submission;
if (existingSubmission) { if (existingSubmission) {
this.deleteLocalSubmissionFile(existingSubmission.file_path);
submission = await taskRepository.updateSubmission(existingSubmission.id, submissionData); submission = await taskRepository.updateSubmission(existingSubmission.id, submissionData);
} else { } else {
submission = await taskRepository.createSubmission(submissionData); 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 = {}) { async validateProjectAccess(idProject, actor = {}) {
if (!actor?.id || !actor?.role) { if (!actor?.id || !actor?.role) {
return new ForbiddenError('Access denied: authentication data is missing'); 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) { isPositiveInteger(value) {
const number = Number(value); const number = Number(value);
return Number.isInteger(number) && number > 0; return Number.isInteger(number) && number > 0;
+120
View File
@@ -594,4 +594,124 @@ router.post(
taskController.submitTask 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; module.exports = router;