From ba352f49506b53e97261427cbf8b506a5493edbf Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Sat, 4 Jul 2026 17:03:15 +0700 Subject: [PATCH] feat(task): submission dengan multiple files sebanyak 20 files --- .../migration.sql | 16 +++ prisma/schema.prisma | 20 ++- prisma/seed.js | 55 ++++++++- .../repositories/project.repository.js | 13 ++ .../project/services/project.service.js | 2 + .../task/controllers/task.controller.js | 42 +++++-- src/modules/task/helpers/taskUpload.helper.js | 2 +- .../task/repositories/task.repository.js | 116 ++++++++++++++++-- src/modules/task/services/task.service.js | 104 +++++++++++++--- src/routes/project.routes.js | 34 +++++ src/routes/task.routes.js | 61 ++++++++- 11 files changed, 419 insertions(+), 46 deletions(-) create mode 100644 prisma/migrations/20260704082209_add_multiple_submission_files/migration.sql diff --git a/prisma/migrations/20260704082209_add_multiple_submission_files/migration.sql b/prisma/migrations/20260704082209_add_multiple_submission_files/migration.sql new file mode 100644 index 0000000..eceac1e --- /dev/null +++ b/prisma/migrations/20260704082209_add_multiple_submission_files/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE `task_submission_file` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `id_submission` INTEGER NOT NULL, + `file_path` TEXT NOT NULL, + `original_name` VARCHAR(255) NULL, + `mime_type` VARCHAR(255) NULL, + `size_bytes` INTEGER NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `task_submission_file_id_submission_foreign`(`id_submission`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `task_submission_file` ADD CONSTRAINT `task_submission_file_id_submission_foreign` FOREIGN KEY (`id_submission`) REFERENCES `task_submission`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 13b8a48..d9cca46 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -277,8 +277,9 @@ model TaskSubmission { submitted_at DateTime @default(now()) updated_at DateTime @updatedAt - task Task @relation(fields: [id_task], references: [id], map: "task_submission_id_task_foreign") - user User @relation(fields: [id_user], references: [id], map: "task_submission_id_user_foreign") + task Task @relation(fields: [id_task], references: [id], map: "task_submission_id_task_foreign") + user User @relation(fields: [id_user], references: [id], map: "task_submission_id_user_foreign") + files TaskSubmissionFile[] @@unique([id_task, id_user]) @@index([id_task], map: "task_submission_id_task_foreign") @@ -286,6 +287,21 @@ model TaskSubmission { @@map("task_submission") } +model TaskSubmissionFile { + id Int @id @default(autoincrement()) + id_submission Int + file_path String @db.Text + original_name String? @db.VarChar(255) + mime_type String? @db.VarChar(255) + size_bytes Int? + created_at DateTime @default(now()) + + submission TaskSubmission @relation(fields: [id_submission], references: [id], onDelete: Cascade, map: "task_submission_file_id_submission_foreign") + + @@index([id_submission], map: "task_submission_file_id_submission_foreign") + @@map("task_submission_file") +} + enum Role { admin mentor diff --git a/prisma/seed.js b/prisma/seed.js index 537b4d1..0c003b4 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -5,6 +5,7 @@ const { getProjectBackgroundColor } = require('../src/helpers/utils/projectTheme // Diperlukan ketika isi databasenya masih kosong dan ingin menambahkan dummy data sebagai testing development [Rafi 08/06/2026 16:34] async function main() { + await prisma.taskSubmissionFile.deleteMany({}); await prisma.taskSubmission.deleteMany({}); await prisma.projectMember.deleteMany({}); await prisma.user.deleteMany({}); @@ -288,12 +289,54 @@ async function main() { }); // 15. TaskSubmission - await prisma.taskSubmission.createMany({ - data: [ - { id_task: task1.id, id_user: user1.id, url_link: 'https://github.com/rafiathallah3' }, - { id_task: task2.id, id_user: user2.id, file_path: 'https://example.com/submission-alice.zip' }, - { id_task: task3.id, id_user: user3.id, url_link: 'https://github.com/bob/auth-gateway' } - ] + await prisma.taskSubmission.create({ + data: { + id_task: task1.id, + id_user: user1.id, + url_link: 'https://github.com/rafiathallah3', + }, + }); + + await prisma.taskSubmission.create({ + data: { + id_task: task2.id, + id_user: user2.id, + + // Backward compatibility untuk frontend lama + file_path: '/uploads/task-submissions/alice-api-specification-part-1.pdf', + + // Multi-file submission data + files: { + create: [ + { + file_path: '/uploads/task-submissions/alice-api-specification-part-1.pdf', + original_name: 'alice-api-specification-part-1.pdf', + mime_type: 'application/pdf', + size_bytes: 204800, + }, + { + file_path: '/uploads/task-submissions/alice-api-specification-part-2.pdf', + original_name: 'alice-api-specification-part-2.pdf', + mime_type: 'application/pdf', + size_bytes: 256000, + }, + { + file_path: '/uploads/task-submissions/alice-api-specification-appendix.pdf', + original_name: 'alice-api-specification-appendix.pdf', + mime_type: 'application/pdf', + size_bytes: 128000, + }, + ], + }, + }, + }); + + await prisma.taskSubmission.create({ + data: { + id_task: task3.id, + id_user: user3.id, + url_link: 'https://github.com/bob/auth-gateway', + }, }); } diff --git a/src/modules/project/repositories/project.repository.js b/src/modules/project/repositories/project.repository.js index 71351e8..8ad621e 100644 --- a/src/modules/project/repositories/project.repository.js +++ b/src/modules/project/repositories/project.repository.js @@ -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', + } + } }, }, }, diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index 0dda444..80b02f7 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -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, diff --git a/src/modules/task/controllers/task.controller.js b/src/modules/task/controllers/task.controller.js index 4e14bda..f29e9d8 100644 --- a/src/modules/task/controllers/task.controller.js +++ b/src/modules/task/controllers/task.controller.js @@ -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, diff --git a/src/modules/task/helpers/taskUpload.helper.js b/src/modules/task/helpers/taskUpload.helper.js index 58d76a8..e85df3e 100644 --- a/src/modules/task/helpers/taskUpload.helper.js +++ b/src/modules/task/helpers/taskUpload.helper.js @@ -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, }); diff --git a/src/modules/task/repositories/task.repository.js b/src/modules/task/repositories/task.repository.js index f272093..11a89ee 100644 --- a/src/modules/task/repositories/task.repository.js +++ b/src/modules/task/repositories/task.repository.js @@ -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(); diff --git a/src/modules/task/services/task.service.js b/src/modules/task/services/task.service.js index bc45186..85b44e0 100644 --- a/src/modules/task/services/task.service.js +++ b/src/modules/task/services/task.service.js @@ -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; diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 8035280..e656a6c 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -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 diff --git a/src/routes/task.routes.js b/src/routes/task.routes.js index 37eed35..f6dfce1 100644 --- a/src/routes/task.routes.js +++ b/src/routes/task.routes.js @@ -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 );