feat(task): submission dengan multiple files sebanyak 20 files
This commit is contained in:
@@ -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;
|
||||||
@@ -279,6 +279,7 @@ model TaskSubmission {
|
|||||||
|
|
||||||
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
@@ -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,
|
||||||
|
|||||||
@@ -17,10 +17,26 @@ const getActor = (req) => ({
|
|||||||
email: req.email,
|
email: req.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleanupUploadedFile = (file) => {
|
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)) {
|
if (file?.path && fs.existsSync(file.path)) {
|
||||||
fs.unlinkSync(file.path);
|
fs.unlinkSync(file.path);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
class TaskController {
|
class TaskController {
|
||||||
@@ -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,23 +192,86 @@ 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) => {
|
||||||
|
const submissionId = parseInt(idSubmission);
|
||||||
|
|
||||||
|
await tx.taskSubmission.update({
|
||||||
where: {
|
where: {
|
||||||
id: parseInt(idSubmission),
|
id: submissionId,
|
||||||
},
|
},
|
||||||
data: submissionData,
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSubmission(idSubmission) {
|
async deleteSubmission(idSubmission) {
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user