Merge pull request #31 from internship-humic/feat/multiple-file-submission

feat(task): submission dengan multiple files sebanyak 20 files
This commit is contained in:
Muhammad Zhafran Ilham
2026-07-04 17:05:47 +07:00
committed by GitHub
11 changed files with 419 additions and 46 deletions
@@ -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;
+18 -2
View File
@@ -277,8 +277,9 @@ model TaskSubmission {
submitted_at DateTime @default(now()) submitted_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
task Task @relation(fields: [id_task], references: [id], map: "task_submission_id_task_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") user User @relation(fields: [id_user], references: [id], map: "task_submission_id_user_foreign")
files TaskSubmissionFile[]
@@unique([id_task, id_user]) @@unique([id_task, id_user])
@@index([id_task], map: "task_submission_id_task_foreign") @@index([id_task], map: "task_submission_id_task_foreign")
@@ -286,6 +287,21 @@ model TaskSubmission {
@@map("task_submission") @@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 { enum Role {
admin admin
mentor mentor
+49 -6
View File
@@ -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] // Diperlukan ketika isi databasenya masih kosong dan ingin menambahkan dummy data sebagai testing development [Rafi 08/06/2026 16:34]
async function main() { async function main() {
await prisma.taskSubmissionFile.deleteMany({});
await prisma.taskSubmission.deleteMany({}); await prisma.taskSubmission.deleteMany({});
await prisma.projectMember.deleteMany({}); await prisma.projectMember.deleteMany({});
await prisma.user.deleteMany({}); await prisma.user.deleteMany({});
@@ -288,12 +289,54 @@ async function main() {
}); });
// 15. TaskSubmission // 15. TaskSubmission
await prisma.taskSubmission.createMany({ await prisma.taskSubmission.create({
data: [ data: {
{ id_task: task1.id, id_user: user1.id, url_link: 'https://github.com/rafiathallah3' }, id_task: task1.id,
{ id_task: task2.id, id_user: user2.id, file_path: 'https://example.com/submission-alice.zip' }, id_user: user1.id,
{ id_task: task3.id, id_user: user3.id, url_link: 'https://github.com/bob/auth-gateway' } 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',
},
}); });
} }
@@ -331,6 +331,19 @@ class ProjectRepository {
url_link: true, url_link: true,
submitted_at: true, submitted_at: true,
updated_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 ? { submission_details: submission ? {
id: submission.id, id: submission.id,
file_path: submission.file_path, file_path: submission.file_path,
files: submission.files || [],
file_paths: (submission.files || []).map((file) => file.file_path),
url_link: submission.url_link, url_link: submission.url_link,
submitted_at: submission.submitted_at, submitted_at: submission.submitted_at,
updated_at: submission.updated_at, updated_at: submission.updated_at,
+31 -11
View File
@@ -17,9 +17,25 @@ const getActor = (req) => ({
email: req.email, email: req.email,
}); });
const cleanupUploadedFile = (file) => { const getUploadedSubmissionFiles = (req) => {
if (file?.path && fs.existsSync(file.path)) { const multipleFiles = Array.isArray(req.files?.submission_files)
fs.unlinkSync(file.path); ? 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) { async submitTask(req, res) {
const uploadedFiles = getUploadedSubmissionFiles(req);
try { try {
const validatePayload = isValidPayload(req.body, submitTaskModel); const validatePayload = isValidPayload(req.body, submitTaskModel);
if (validatePayload.err) { if (validatePayload.err) {
cleanupUploadedFile(req.file); cleanupUploadedFiles(uploadedFiles);
return response( return response(
res, res,
@@ -190,19 +208,19 @@ class TaskController {
const result = await taskService.submitTask( const result = await taskService.submitTask(
req.params.id, req.params.id,
validatePayload.data, validatePayload.data,
req.file, uploadedFiles,
actor, actor,
req.query.project || req.query.id_project req.query.project || req.query.id_project
); );
if (result.err) { if (result.err) {
cleanupUploadedFile(req.file); cleanupUploadedFiles(uploadedFiles);
return response(res, 'fail', result); return response(res, 'fail', result);
} }
return response(res, 'success', result, 'Task submitted successfully', SUCCESS.OK); return response(res, 'success', result, 'Task submitted successfully', SUCCESS.OK);
} catch (err) { } catch (err) {
cleanupUploadedFile(req.file); cleanupUploadedFiles(uploadedFiles);
return response( return response(
res, res,
@@ -215,11 +233,13 @@ class TaskController {
} }
async updateSubmission(req, res) { async updateSubmission(req, res) {
const uploadedFiles = getUploadedSubmissionFiles(req);
try { try {
const validatePayload = isValidPayload(req.body, updateSubmissionModel); const validatePayload = isValidPayload(req.body, updateSubmissionModel);
if (validatePayload.err) { if (validatePayload.err) {
cleanupUploadedFile(req.file); cleanupUploadedFiles(uploadedFiles);
return response( return response(
res, res,
@@ -234,18 +254,18 @@ class TaskController {
const result = await taskService.updateSubmission( const result = await taskService.updateSubmission(
req.params.id, req.params.id,
validatePayload.data, validatePayload.data,
req.file, uploadedFiles,
actor actor
); );
if (result.err) { if (result.err) {
cleanupUploadedFile(req.file); cleanupUploadedFiles(uploadedFiles);
return response(res, 'fail', result); return response(res, 'fail', result);
} }
return response(res, 'success', result, 'Submission updated successfully', SUCCESS.OK); return response(res, 'success', result, 'Submission updated successfully', SUCCESS.OK);
} catch (err) { } catch (err) {
cleanupUploadedFile(req.file); cleanupUploadedFiles(uploadedFiles);
return response( return response(
res, res,
@@ -63,7 +63,7 @@ const taskUploadErrorHandler = (err, _req, res, next) => {
status: false, status: false,
data: null, data: null,
message: { 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, code: 400,
}); });
@@ -44,6 +44,12 @@ class TaskRepository {
url_link: true, url_link: true,
submitted_at: true, submitted_at: true,
updated_at: true, updated_at: true,
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc'
}
}
}, },
}, },
}, },
@@ -91,6 +97,12 @@ class TaskRepository {
professional_bio: true, professional_bio: true,
}, },
}, },
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc'
}
}
}, },
}, },
}, },
@@ -140,6 +152,14 @@ class TaskRepository {
id_user: parseInt(idUser), id_user: parseInt(idUser),
}, },
}, },
include: {
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc',
}
}
}
}); });
} }
@@ -172,22 +192,85 @@ class TaskRepository {
email: true, email: true,
}, },
}, },
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc',
}
}
}, },
}); });
} }
async createSubmission(submissionData) { async createSubmission(submissionData, filesData = []) {
return prisma.taskSubmission.create({ 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) { async updateSubmission(idSubmission, submissionData, filesData = null) {
return prisma.taskSubmission.update({ return prisma.$transaction(async (tx) => {
where: { const submissionId = parseInt(idSubmission);
id: parseInt(idSubmission),
}, await tx.taskSubmission.update({
data: submissionData, 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, professional_bio: true,
}, },
}, },
files: {
select: this.submissionFileSelect(),
orderBy: {
id: 'asc'
}
}
}, },
}, },
}, },
@@ -293,6 +382,17 @@ class TaskRepository {
return Boolean(task); 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(); 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 { try {
if (!idTask) { if (!idTask) {
return error(new BadRequestError('Task ID or slug is required')); return error(new BadRequestError('Task ID or slug is required'));
@@ -288,21 +288,29 @@ class TaskService {
id_user: parseInt(actor.id), id_user: parseInt(actor.id),
}; };
let filesData = [];
if (task.submission_type === 'file_upload') { if (task.submission_type === 'file_upload') {
if (!file) { if (!files || files.length === 0) {
return error(new BadRequestError('Submission file is required for this task')); 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) { if (payload.url_link) {
return error(new BadRequestError('URL link is not allowed for file upload task')); 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; submissionData.url_link = null;
} }
if (task.submission_type === 'url_link') { 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')); return error(new BadRequestError('File upload is not allowed for URL link task'));
} }
@@ -319,10 +327,17 @@ class TaskService {
let submission; let submission;
if (existingSubmission) { if (existingSubmission) {
this.deleteLocalSubmissionFile(existingSubmission.file_path); const oldFilePaths = this.getSubmissionFilePaths(existingSubmission);
submission = await taskRepository.updateSubmission(existingSubmission.id, submissionData);
submission = await taskRepository.updateSubmission(
existingSubmission.id,
submissionData,
task.submission_type === 'file_upload' ? filesData : []
);
this.deleteLocalSubmissionFiles(oldFilePaths);
} else { } else {
submission = await taskRepository.createSubmission(submissionData); submission = await taskRepository.createSubmission(submissionData, filesData);
} }
const projectDetails = await taskRepository.findProjectById(task.id_project); 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 { try {
if (!this.isPositiveInteger(idSubmission)) { if (!this.isPositiveInteger(idSubmission)) {
return error(new BadRequestError('Submission ID must be a valid number')); return error(new BadRequestError('Submission ID must be a valid number'));
@@ -380,22 +395,29 @@ class TaskService {
} }
const updateData = {}; const updateData = {};
let filesData = null;
if (submission.task.submission_type === 'file_upload') { 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')); 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) { if (payload.url_link) {
return error(new BadRequestError('URL link is not allowed for file upload task')); 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; updateData.url_link = null;
} }
if (submission.task.submission_type === 'url_link') { 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')); 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')); return error(new BadRequestError('URL link is required for this task'));
} }
filesData = [];
updateData.file_path = null; updateData.file_path = null;
updateData.url_link = payload.url_link; updateData.url_link = payload.url_link;
} }
this.deleteLocalSubmissionFile(submission.file_path); const oldFilePaths = this.getSubmissionFilePaths(submission);
const updatedSubmission = await taskRepository.updateSubmission( const updatedSubmission = await taskRepository.updateSubmission(
idSubmission, idSubmission,
updateData updateData,
filesData
); );
this.deleteLocalSubmissionFiles(oldFilePaths);
return data(this.mapSubmission(updatedSubmission)); return data(this.mapSubmission(updatedSubmission));
} catch (err) { } catch (err) {
return error(err); return error(err);
@@ -455,7 +481,7 @@ class TaskService {
await taskRepository.deleteSubmission(idSubmission); await taskRepository.deleteSubmission(idSubmission);
this.deleteLocalSubmissionFile(submission.file_path); this.deleteLocalSubmissionFiles(this.getSubmissionFilePaths(submission));
return data(null); return data(null);
} catch (err) { } catch (err) {
@@ -582,11 +608,28 @@ class TaskService {
mapSubmission(submission) { mapSubmission(submission) {
if (!submission) return null; 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 { return {
id: submission.id, id: submission.id,
id_task: submission.id_task, id_task: submission.id_task,
id_user: submission.id_user, id_user: submission.id_user,
// Backward compatibility when only file_path is available (1 file per submission)
file_path: submission.file_path, file_path: submission.file_path,
// Multi-file fields
files,
file_paths: files.map((file) => file.file_path),
url_link: submission.url_link, url_link: submission.url_link,
submitted_at: submission.submitted_at, submitted_at: submission.submitted_at,
updated_at: submission.updated_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) { deleteLocalSubmissionFile(filePath) {
if (!filePath || typeof filePath !== 'string') { if (!filePath || typeof filePath !== 'string') {
return; return;
+34
View File
@@ -426,6 +426,40 @@ const router = express.Router();
* type: string * type: string
* format: date-time * format: date-time
* example: 2026-07-20T10:00:00.000Z * 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: * updated_at:
* type: string * type: string
* format: date-time * format: date-time
+57 -4
View File
@@ -113,6 +113,40 @@ const router = express.Router();
* type: string * type: string
* nullable: true * nullable: true
* example: /uploads/task-submissions/report.pdf * 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: * url_link:
* type: string * type: string
* nullable: true * nullable: true
@@ -673,6 +707,11 @@ router.patch('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.updateTask
*/ */
router.delete('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.deleteTask); router.delete('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.deleteTask);
const submissionUpload = taskUpload.fields([
{ name: 'submission_files', maxCount: 20 },
{ name: 'submission_file', maxCount: 1 },
]);
/** /**
* @swagger * @swagger
* /task-api/tasks/{id}/submissions: * /task-api/tasks/{id}/submissions:
@@ -712,10 +751,17 @@ router.delete('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.deleteTas
* schema: * schema:
* type: object * type: object
* properties: * 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: * submission_file:
* type: string * type: string
* format: binary * 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: * url_link:
* type: string * type: string
* format: uri * format: uri
@@ -751,7 +797,7 @@ router.delete('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.deleteTas
router.post( router.post(
'/tasks/:id/submissions', '/tasks/:id/submissions',
verifyJWT, verifyJWT,
taskUpload.single('submission_file'), submissionUpload,
taskUploadErrorHandler, taskUploadErrorHandler,
taskController.submitTask taskController.submitTask
); );
@@ -780,10 +826,17 @@ router.post(
* schema: * schema:
* type: object * type: object
* properties: * 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: * submission_file:
* type: string * type: string
* format: binary * 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: * url_link:
* type: string * type: string
* format: uri * format: uri
@@ -819,7 +872,7 @@ router.post(
router.patch( router.patch(
'/submissions/:id', '/submissions/:id',
verifyJWT, verifyJWT,
taskUpload.single('submission_file'), submissionUpload,
taskUploadErrorHandler, taskUploadErrorHandler,
taskController.updateSubmission taskController.updateSubmission
); );