feat: menambahkan API update dan delete submission
This commit is contained in:
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user