feat(task): submission dengan multiple files sebanyak 20 files

This commit is contained in:
Muhammad Zhafran Ilham
2026-07-04 17:03:15 +07:00
parent 177051ea89
commit ba352f4950
11 changed files with 419 additions and 46 deletions
@@ -331,6 +331,19 @@ class ProjectRepository {
url_link: true,
submitted_at: true,
updated_at: true,
files: {
select: {
id: true,
file_path: true,
original_name: true,
mime_type: true,
size_bytes: true,
created_at: true,
},
orderBy: {
id: 'asc',
}
}
},
},
},
@@ -523,6 +523,8 @@ class ProjectService {
submission_details: submission ? {
id: submission.id,
file_path: submission.file_path,
files: submission.files || [],
file_paths: (submission.files || []).map((file) => file.file_path),
url_link: submission.url_link,
submitted_at: submission.submitted_at,
updated_at: submission.updated_at,
+31 -11
View File
@@ -17,9 +17,25 @@ const getActor = (req) => ({
email: req.email,
});
const cleanupUploadedFile = (file) => {
if (file?.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
const getUploadedSubmissionFiles = (req) => {
const multipleFiles = Array.isArray(req.files?.submission_files)
? req.files.submission_files
: [];
const legacyFiles = Array.isArray(req.files?.submission_file)
? req.files.submission_file
: [];
const singleFile = req.file ? [req.file] : [];
return [...multipleFiles, ...legacyFiles, ...singleFile];
};
const cleanupUploadedFiles = (files = []) => {
for (const file of files) {
if (file?.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
}
};
@@ -171,11 +187,13 @@ class TaskController {
}
async submitTask(req, res) {
const uploadedFiles = getUploadedSubmissionFiles(req);
try {
const validatePayload = isValidPayload(req.body, submitTaskModel);
if (validatePayload.err) {
cleanupUploadedFile(req.file);
cleanupUploadedFiles(uploadedFiles);
return response(
res,
@@ -190,19 +208,19 @@ class TaskController {
const result = await taskService.submitTask(
req.params.id,
validatePayload.data,
req.file,
uploadedFiles,
actor,
req.query.project || req.query.id_project
);
if (result.err) {
cleanupUploadedFile(req.file);
cleanupUploadedFiles(uploadedFiles);
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Task submitted successfully', SUCCESS.OK);
} catch (err) {
cleanupUploadedFile(req.file);
cleanupUploadedFiles(uploadedFiles);
return response(
res,
@@ -215,11 +233,13 @@ class TaskController {
}
async updateSubmission(req, res) {
const uploadedFiles = getUploadedSubmissionFiles(req);
try {
const validatePayload = isValidPayload(req.body, updateSubmissionModel);
if (validatePayload.err) {
cleanupUploadedFile(req.file);
cleanupUploadedFiles(uploadedFiles);
return response(
res,
@@ -234,18 +254,18 @@ class TaskController {
const result = await taskService.updateSubmission(
req.params.id,
validatePayload.data,
req.file,
uploadedFiles,
actor
);
if (result.err) {
cleanupUploadedFile(req.file);
cleanupUploadedFiles(uploadedFiles);
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Submission updated successfully', SUCCESS.OK);
} catch (err) {
cleanupUploadedFile(req.file);
cleanupUploadedFiles(uploadedFiles);
return response(
res,
@@ -63,7 +63,7 @@ const taskUploadErrorHandler = (err, _req, res, next) => {
status: false,
data: null,
message: {
[err.field]: `Field '${err.field}' cannot have more than 1 file.`,
[err.field]: `Field '${err.field}' exceeds the allowed file count or is not allowed.`,
},
code: 400,
});
@@ -44,6 +44,12 @@ class TaskRepository {
url_link: true,
submitted_at: true,
updated_at: true,
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc'
}
}
},
},
},
@@ -91,6 +97,12 @@ class TaskRepository {
professional_bio: true,
},
},
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc'
}
}
},
},
},
@@ -140,6 +152,14 @@ class TaskRepository {
id_user: parseInt(idUser),
},
},
include: {
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc',
}
}
}
});
}
@@ -172,22 +192,85 @@ class TaskRepository {
email: true,
},
},
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc',
}
}
},
});
}
async createSubmission(submissionData) {
async createSubmission(submissionData, filesData = []) {
return prisma.taskSubmission.create({
data: submissionData,
data: {
...submissionData,
files: filesData.length > 0
? {
create: filesData,
}
: undefined,
},
include: {
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc',
},
},
},
});
}
async updateSubmission(idSubmission, submissionData) {
return prisma.taskSubmission.update({
where: {
id: parseInt(idSubmission),
},
data: submissionData,
async updateSubmission(idSubmission, submissionData, filesData = null) {
return prisma.$transaction(async (tx) => {
const submissionId = parseInt(idSubmission);
await tx.taskSubmission.update({
where: {
id: submissionId,
},
data: submissionData,
});
if (filesData !== null) {
await tx.taskSubmissionFile.deleteMany({
where: {
id_submission: submissionId,
},
});
if (filesData.length > 0) {
await tx.taskSubmissionFile.createMany({
data: filesData.map((file) => ({
...file,
id_submission: submissionId,
})),
});
}
}
return tx.taskSubmission.findUnique({
where: {
id: submissionId,
},
include: {
files: {
select: {
id: true,
file_path: true,
original_name: true,
mime_type: true,
size_bytes: true,
created_at: true,
},
orderBy: {
id: 'asc',
},
},
},
});
});
}
@@ -266,6 +349,12 @@ class TaskRepository {
professional_bio: true,
},
},
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc'
}
}
},
},
},
@@ -293,6 +382,17 @@ class TaskRepository {
return Boolean(task);
}
submissionFileSelect() {
return {
id: true,
file_path: true,
original_name: true,
mime_type: true,
size_bytes: true,
created_at: true,
};
}
}
module.exports = new TaskRepository();
+90 -14
View File
@@ -257,7 +257,7 @@ class TaskService {
}
}
async submitTask(idTask, payload, file, actor = {}, projectIdentifier = null) {
async submitTask(idTask, payload, files = [], actor = {}, projectIdentifier = null) {
try {
if (!idTask) {
return error(new BadRequestError('Task ID or slug is required'));
@@ -288,21 +288,29 @@ class TaskService {
id_user: parseInt(actor.id),
};
let filesData = [];
if (task.submission_type === 'file_upload') {
if (!file) {
if (!files || files.length === 0) {
return error(new BadRequestError('Submission file is required for this task'));
}
if (files.length > 20) {
return error(new BadRequestError('Maximum 20 submission files are allowed'));
}
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}`;
filesData = this.mapUploadedSubmissionFiles(files);
submissionData.file_path = filesData[0]?.file_path || null;
submissionData.url_link = null;
}
if (task.submission_type === 'url_link') {
if (file) {
if (files && files.length > 0) {
return error(new BadRequestError('File upload is not allowed for URL link task'));
}
@@ -319,10 +327,17 @@ class TaskService {
let submission;
if (existingSubmission) {
this.deleteLocalSubmissionFile(existingSubmission.file_path);
submission = await taskRepository.updateSubmission(existingSubmission.id, submissionData);
const oldFilePaths = this.getSubmissionFilePaths(existingSubmission);
submission = await taskRepository.updateSubmission(
existingSubmission.id,
submissionData,
task.submission_type === 'file_upload' ? filesData : []
);
this.deleteLocalSubmissionFiles(oldFilePaths);
} else {
submission = await taskRepository.createSubmission(submissionData);
submission = await taskRepository.createSubmission(submissionData, filesData);
}
const projectDetails = await taskRepository.findProjectById(task.id_project);
@@ -346,7 +361,7 @@ class TaskService {
}
}
async updateSubmission(idSubmission, payload, file, actor = {}) {
async updateSubmission(idSubmission, payload, files = [], actor = {}) {
try {
if (!this.isPositiveInteger(idSubmission)) {
return error(new BadRequestError('Submission ID must be a valid number'));
@@ -380,22 +395,29 @@ class TaskService {
}
const updateData = {};
let filesData = null;
if (submission.task.submission_type === 'file_upload') {
if (!file) {
if (!files || files.length === 0) {
return error(new BadRequestError('Submission file is required for this task'));
}
if (files.length > 20) {
return error(new BadRequestError('Maximum 20 submission files are allowed'));
}
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}`;
filesData = this.mapUploadedSubmissionFiles(files);
updateData.file_path = filesData[0]?.file_path || null;
updateData.url_link = null;
}
if (submission.task.submission_type === 'url_link') {
if (file) {
if (files && files.length > 0) {
return error(new BadRequestError('File upload is not allowed for URL link task'));
}
@@ -403,17 +425,21 @@ class TaskService {
return error(new BadRequestError('URL link is required for this task'));
}
filesData = [];
updateData.file_path = null;
updateData.url_link = payload.url_link;
}
this.deleteLocalSubmissionFile(submission.file_path);
const oldFilePaths = this.getSubmissionFilePaths(submission);
const updatedSubmission = await taskRepository.updateSubmission(
idSubmission,
updateData
updateData,
filesData
);
this.deleteLocalSubmissionFiles(oldFilePaths);
return data(this.mapSubmission(updatedSubmission));
} catch (err) {
return error(err);
@@ -455,7 +481,7 @@ class TaskService {
await taskRepository.deleteSubmission(idSubmission);
this.deleteLocalSubmissionFile(submission.file_path);
this.deleteLocalSubmissionFiles(this.getSubmissionFilePaths(submission));
return data(null);
} catch (err) {
@@ -582,11 +608,28 @@ class TaskService {
mapSubmission(submission) {
if (!submission) return null;
const files = Array.isArray(submission.files)
? submission.files.map((file) => ({
id: file.id,
file_path: file.file_path,
original_name: file.original_name || null,
mime_type: file.mime_type || null,
size_bytes: file.size_bytes || null,
created_at: file.created_at || null,
}))
: [];
return {
id: submission.id,
id_task: submission.id_task,
id_user: submission.id_user,
// Backward compatibility when only file_path is available (1 file per submission)
file_path: submission.file_path,
// Multi-file fields
files,
file_paths: files.map((file) => file.file_path),
url_link: submission.url_link,
submitted_at: submission.submitted_at,
updated_at: submission.updated_at,
@@ -654,6 +697,39 @@ class TaskService {
};
}
mapUploadedSubmissionFiles(files = []) {
return files.map((file) => ({
file_path: `/uploads/task-submissions/${file.filename}`,
original_name: file.originalname || null,
mime_type: file.mimetype || null,
size_bytes: file.size || null,
}));
}
getSubmissionFilePaths(submission) {
const filePaths = [];
if (Array.isArray(submission?.files)) {
for (const file of submission.files) {
if (file?.file_path) {
filePaths.push(file.file_path);
}
}
}
if (filePaths.length === 0 && submission?.file_path) {
filePaths.push(submission.file_path);
}
return [...new Set(filePaths)];
}
deleteLocalSubmissionFiles(filePaths = []) {
for (const filePath of filePaths) {
this.deleteLocalSubmissionFile(filePath);
}
}
deleteLocalSubmissionFile(filePath) {
if (!filePath || typeof filePath !== 'string') {
return;
+34
View File
@@ -426,6 +426,40 @@ const router = express.Router();
* type: string
* format: date-time
* example: 2026-07-20T10:00:00.000Z
* files:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* example: 1
* file_path:
* type: string
* example: /uploads/task-submissions/report-part-1.pdf
* original_name:
* type: string
* nullable: true
* example: report-part-1.pdf
* mime_type:
* type: string
* nullable: true
* example: application/pdf
* size_bytes:
* type: integer
* nullable: true
* example: 204800
* created_at:
* type: string
* format: date-time
* example: 2026-07-20T10:00:00.000Z
* file_paths:
* type: array
* items:
* type: string
* example:
* - /uploads/task-submissions/report-part-1.pdf
* - /uploads/task-submissions/report-part-2.pdf
* updated_at:
* type: string
* format: date-time
+57 -4
View File
@@ -113,6 +113,40 @@ const router = express.Router();
* type: string
* nullable: true
* example: /uploads/task-submissions/report.pdf
* files:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* example: 1
* file_path:
* type: string
* example: /uploads/task-submissions/report-part-1.pdf
* original_name:
* type: string
* nullable: true
* example: report-part-1.pdf
* mime_type:
* type: string
* nullable: true
* example: application/pdf
* size_bytes:
* type: integer
* nullable: true
* example: 204800
* created_at:
* type: string
* format: date-time
* example: 2026-06-09T10:00:00.000Z
* file_paths:
* type: array
* items:
* type: string
* example:
* - /uploads/task-submissions/report-part-1.pdf
* - /uploads/task-submissions/report-part-2.pdf
* url_link:
* type: string
* nullable: true
@@ -673,6 +707,11 @@ router.patch('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.updateTask
*/
router.delete('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.deleteTask);
const submissionUpload = taskUpload.fields([
{ name: 'submission_files', maxCount: 20 },
{ name: 'submission_file', maxCount: 1 },
]);
/**
* @swagger
* /task-api/tasks/{id}/submissions:
@@ -712,10 +751,17 @@ router.delete('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.deleteTas
* schema:
* type: object
* properties:
* submission_files:
* type: array
* items:
* type: string
* format: binary
* description: Required when task submission_type is file_upload. Send this field multiple times. Max 20 PDF files, 5MB each.
* submission_file:
* type: string
* format: binary
* description: Required when task submission_type is file_upload. Allowed file types are PDF. Max size is 5MB.
* deprecated: true
* description: Deprecated. Use submission_files instead.
* url_link:
* type: string
* format: uri
@@ -751,7 +797,7 @@ router.delete('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.deleteTas
router.post(
'/tasks/:id/submissions',
verifyJWT,
taskUpload.single('submission_file'),
submissionUpload,
taskUploadErrorHandler,
taskController.submitTask
);
@@ -780,10 +826,17 @@ router.post(
* schema:
* type: object
* properties:
* submission_files:
* type: array
* items:
* type: string
* format: binary
* description: Required when task submission_type is file_upload. Send this field multiple times. Max 20 PDF files, 5MB each.
* 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 5MB.
* deprecated: true
* description: Deprecated. Use submission_files instead.
* url_link:
* type: string
* format: uri
@@ -819,7 +872,7 @@ router.post(
router.patch(
'/submissions/:id',
verifyJWT,
taskUpload.single('submission_file'),
submissionUpload,
taskUploadErrorHandler,
taskController.updateSubmission
);