From adc7246dfb9abd6e2c7fe5b197974441b0644164 Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Tue, 9 Jun 2026 14:18:07 +0700 Subject: [PATCH 1/6] docs: menambahkan schema response sukses untuk endpoint /project-api/* --- src/routes/project.routes.js | 387 ++++++++++++++++++++++++++++++++++- 1 file changed, 386 insertions(+), 1 deletion(-) diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 3bb9224..a5ffd6e 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -103,6 +103,355 @@ const router = express.Router(); * type: string * enum: [active, completed, archived] * example: completed + * ProjectAdminSummary: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * full_name: + * type: string + * example: Admin One + * email: + * type: string + * example: admin1@internify.com + * profile_picture: + * type: string + * nullable: true + * example: /uploads/admin-profile.png + * professional_bio: + * type: string + * nullable: true + * example: Senior Program Manager at Internify. + * ProjectListItem: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * project_icon: + * type: string + * example: code + * project_name: + * type: string + * example: Internify Platform Dev + * description: + * type: string + * example: Upgrading the core Internify platform backend and web client. + * start_date: + * type: string + * format: date-time + * example: 2026-07-15T00:00:00.000Z + * end_date: + * type: string + * format: date-time + * example: 2026-10-15T00:00:00.000Z + * max_members: + * type: integer + * example: 8 + * status: + * type: string + * example: active + * admin: + * $ref: '#/components/schemas/ProjectAdminSummary' + * total_members: + * type: integer + * example: 5 + * total_tasks: + * type: integer + * example: 3 + * created_at: + * type: string + * format: date-time + * example: 2026-06-10T10:00:00.000Z + * updated_at: + * type: string + * format: date-time + * example: 2026-06-10T10:00:00.000Z + * ProjectMemberUser: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * full_name: + * type: string + * example: Rafi Athallah + * email: + * type: string + * example: rafi@student.com + * professional_bio: + * type: string + * nullable: true + * example: Backend developer intern + * position: + * type: string + * nullable: true + * example: Web Developer + * kelompok_peminatan: + * type: string + * nullable: true + * example: Software Engineering + * ProjectMemberItem: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * id_user: + * type: integer + * example: 1 + * status: + * type: string + * example: active + * user: + * $ref: '#/components/schemas/ProjectMemberUser' + * created_at: + * type: string + * format: date-time + * example: 2026-06-10T10:00:00.000Z + * updated_at: + * type: string + * format: date-time + * example: 2026-06-10T10:00:00.000Z + * ProjectTaskItem: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * title: + * type: string + * example: Database Setup + * description: + * type: string + * example: Setup database schema and write initial seed script. + * deadline_at: + * type: string + * format: date-time + * example: 2026-08-01T00:00:00.000Z + * submission_type: + * type: string + * enum: [file_upload, url_link] + * example: url_link + * created_at: + * type: string + * format: date-time + * example: 2026-06-10T10:00:00.000Z + * updated_at: + * type: string + * format: date-time + * example: 2026-06-10T10:00:00.000Z + * ProjectDetail: + * allOf: + * - $ref: '#/components/schemas/ProjectListItem' + * - type: object + * properties: + * members: + * type: array + * items: + * $ref: '#/components/schemas/ProjectMemberItem' + * tasks: + * type: array + * items: + * $ref: '#/components/schemas/ProjectTaskItem' + * ProjectListResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: array + * items: + * $ref: '#/components/schemas/ProjectListItem' + * message: + * type: string + * example: Projects retrieved successfully + * code: + * type: integer + * example: 200 + * ProjectDetailResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/ProjectDetail' + * message: + * type: string + * example: Project detail retrieved successfully + * code: + * type: integer + * example: 200 + * InternListItem: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * name: + * type: string + * example: Rafi Athallah + * email: + * type: string + * example: rafi@student.com + * projectName: + * type: string + * example: Internify Platform Dev + * projectId: + * type: integer + * nullable: true + * example: 1 + * role: + * type: string + * example: Web Developer + * isAssignedByMentor: + * type: boolean + * example: true + * avatar: + * type: string + * nullable: true + * example: null + * InternListResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: array + * items: + * $ref: '#/components/schemas/InternListItem' + * message: + * type: string + * example: Interns retrieved successfully + * code: + * type: integer + * example: 200 + * MyTaskItem: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * id_project: + * type: integer + * example: 1 + * project_name: + * type: string + * example: Internify Platform Dev + * project_icon: + * type: string + * example: code + * title: + * type: string + * example: Database Setup + * description: + * type: string + * example: Setup database schema and write initial seed script. + * deadline_at: + * type: string + * format: date-time + * example: 2026-08-01T00:00:00.000Z + * submission_type: + * type: string + * enum: [file_upload, url_link] + * example: url_link + * submission_status: + * type: string + * enum: [submitted, not_submitted] + * example: submitted + * submission_details: + * type: object + * nullable: true + * properties: + * id: + * type: integer + * example: 1 + * file_path: + * type: string + * nullable: true + * example: null + * url_link: + * type: string + * nullable: true + * example: https://github.com/rafiathallah3 + * submitted_at: + * type: string + * format: date-time + * example: 2026-07-20T10:00:00.000Z + * updated_at: + * type: string + * format: date-time + * example: 2026-07-20T10:00:00.000Z + * MyTaskListResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: array + * items: + * $ref: '#/components/schemas/MyTaskItem' + * message: + * type: string + * example: Intern tasks retrieved successfully + * code: + * type: integer + * example: 200 + * AssignMemberResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * id_project: + * type: integer + * example: 1 + * id_user: + * type: integer + * example: 2 + * status: + * type: string + * example: active + * created_at: + * type: string + * format: date-time + * example: 2026-06-10T10:00:00.000Z + * updated_at: + * type: string + * format: date-time + * example: 2026-06-10T10:00:00.000Z + * message: + * type: string + * example: Member assigned successfully + * code: + * type: integer + * example: 200 + * ErrorResponse: + * type: object + * properties: + * status: + * type: boolean + * example: false + * data: + * nullable: true + * example: null + * message: + * type: string + * example: Validation error + * code: + * type: integer + * example: 417 */ /** @@ -123,6 +472,10 @@ const router = express.Router(); * responses: * 201: * description: Project created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProjectDetailResponse' * 400: * description: Bad request * 401: @@ -159,6 +512,10 @@ router.post('/add', verifyJWT, isAdmin, projectController.createProject); * responses: * 200: * description: Projects retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProjectListResponse' * 401: * description: Unauthorized * 417: @@ -188,6 +545,10 @@ router.get('/get', verifyJWT, projectController.getAllProjects); * responses: * 200: * description: Project detail retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProjectDetailResponse' * 401: * description: Unauthorized * 403: @@ -221,10 +582,14 @@ router.get('/get/:id', verifyJWT, projectController.getProjectById); * content: * application/json: * schema: - * $ref: '#/components/schemas/ProjectUpdateRequest' + * $ref: '#/components/schemas/ProjectDetailResponse' * responses: * 200: * description: Project updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProjectUpdateResponse' * 400: * description: Bad request * 401: @@ -260,6 +625,10 @@ router.patch('/update/:id', verifyJWT, isAdmin, projectController.updateProject) * responses: * 200: * description: Project archived successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProjectDetailResponse' * 401: * description: Unauthorized * 403: @@ -283,6 +652,10 @@ router.delete('/delete/:id', verifyJWT, isAdmin, projectController.archiveProjec * responses: * 200: * description: Intern projects retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProjectListResponse' * 401: * description: Unauthorized * 403: @@ -304,6 +677,10 @@ router.get('/my-projects', verifyJWT, projectController.getMyProjects); * responses: * 200: * description: Intern tasks retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/MyTaskListResponse' * 401: * description: Unauthorized * 403: @@ -325,6 +702,10 @@ router.get('/my-tasks', verifyJWT, projectController.getMyTasks); * responses: * 200: * description: Mentor projects retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProjectListResponse' * 401: * description: Unauthorized * 403: @@ -421,6 +802,10 @@ router.get('/interns', verifyJWT, isAdmin, projectController.getInterns); * responses: * 200: * description: Member assigned successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AssignMemberResponse' * 400: * description: Bad request * 401: From 1eaa791081e9b2975eb07f6768a1f49f9e30d724 Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Tue, 9 Jun 2026 19:58:00 +0700 Subject: [PATCH 2/6] docs: memperbaiki skema body request untuk PATCH /project-api/update/{id} --- src/routes/project.routes.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index a5ffd6e..49655f9 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -582,14 +582,21 @@ router.get('/get/:id', verifyJWT, projectController.getProjectById); * content: * application/json: * schema: - * $ref: '#/components/schemas/ProjectDetailResponse' + * $ref: '#/components/schemas/ProjectUpdateRequest' + * example: + * project_icon: cloud + * project_name: Internify LMS Updated + * description: Updated project description + * start_date: 2026-06-15 + * end_date: 2026-08-20 + * status: completed * responses: * 200: * description: Project updated successfully * content: * application/json: * schema: - * $ref: '#/components/schemas/ProjectUpdateResponse' + * $ref: '#/components/schemas/ProjectDetailResponse' * 400: * description: Bad request * 401: From e14ee25ef38bef54f274411d4187b0023de90f3a Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Tue, 9 Jun 2026 20:45:03 +0700 Subject: [PATCH 3/6] feat: Invite intern ke project ketika creare project hanya bisa jika intern tidak sedang memiliki active project lain --- .../repositories/project.repository.js | 26 +++++++++++++++++++ .../project/services/project.service.js | 24 +++++++++++++++-- src/routes/project.routes.js | 3 ++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/modules/project/repositories/project.repository.js b/src/modules/project/repositories/project.repository.js index 5b941cf..e2c989c 100644 --- a/src/modules/project/repositories/project.repository.js +++ b/src/modules/project/repositories/project.repository.js @@ -390,6 +390,32 @@ class ProjectRepository { data: { status }, }); } + + async findActiveMembershipsByUserIds(userIds) { + return prisma.projectMember.findMany({ + where: { + id_user: { + in: userIds.map((id) => parseInt(id)), + }, + status: "active", + }, + include: { + user: { + select: { + id: true, + full_name: true, + email: true, + }, + }, + project: { + select: { + id: true, + project_name: true, + }, + }, + }, + }); + } } module.exports = new ProjectRepository(); diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index d1775d1..a5c64a0 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -58,6 +58,27 @@ class ProjectService { } } + const userIds = users.map((user) => user.id); + + if (userIds.length > 0) { + const activeMemberships = await projectRepository.findActiveMembershipsByUserIds(userIds); + + if (activeMemberships.length > 0) { + const assignedInterns = activeMemberships.map((membership) => { + const email = membership.user?.email || "unknown email"; + const projectName = membership.project?.project_name || "Unknown Project"; + + return `${email} already assigned to ${projectName}`; + }); + + return error( + new ConflictError( + `One or more interns already have an active project: ${assignedInterns.join(", ")}` + ) + ); + } + } + const newProjectData = { id_admin: parseInt(actor.id), project_icon: projectData.project_icon, @@ -69,7 +90,6 @@ class ProjectService { status: "active", }; - const userIds = users.map((user) => user.id); const project = await projectRepository.createWithMembers( newProjectData, userIds, @@ -441,7 +461,7 @@ class ProjectService { const activeMembership = intern.project_members?.[0] || null; const projectName = activeMembership?.project?.project_name || "Unassigned"; const role = intern.lamaran_magang?.lowongan_magang?.posisi || "-"; - + return { id: intern.id, name: intern.full_name, diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 49655f9..a695391 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -74,6 +74,7 @@ const router = express.Router(); * example: 2026-08-10 * member_emails: * type: array + * description: Optional intern emails to invite. Each intern must already have an active user account and must not be assigned to another active project. * items: * type: string * format: email @@ -459,7 +460,7 @@ const router = express.Router(); * /project-api/add: * post: * summary: Create a new project - * description: Create a new Internify LMS project. Admin can optionally invite interns by email. + * description: Create a new Internify LMS project. Admin can optionally invite interns by email. Invited interns must not be active members of another project. * tags: [Project] * security: * - bearerAuth: [] From 7e6b56fbe01aeedb7935e1c66a0b1cc8c3a276b0 Mon Sep 17 00:00:00 2001 From: Rafi Athallah <65345768+rafiathallah3@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:14:27 +0700 Subject: [PATCH 4/6] feat: nambahin certificate system --- .../migration.sql | 20 + prisma/schema.prisma | 25 +- src/index.js | 2 + .../controllers/certificate.controller.js | 177 +++++++++ src/modules/certificate/index.js | 5 + .../certificate/models/certificate.model.js | 13 + .../repositories/certificate.repository.js | 118 ++++++ .../services/certificate.service.js | 225 +++++++++++ .../project/services/project.service.js | 5 - src/routes/certificate.routes.js | 369 ++++++++++++++++++ 10 files changed, 951 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260609140242_add_certificate/migration.sql create mode 100644 src/modules/certificate/controllers/certificate.controller.js create mode 100644 src/modules/certificate/index.js create mode 100644 src/modules/certificate/models/certificate.model.js create mode 100644 src/modules/certificate/repositories/certificate.repository.js create mode 100644 src/modules/certificate/services/certificate.service.js create mode 100644 src/routes/certificate.routes.js diff --git a/prisma/migrations/20260609140242_add_certificate/migration.sql b/prisma/migrations/20260609140242_add_certificate/migration.sql new file mode 100644 index 0000000..7cd5e6e --- /dev/null +++ b/prisma/migrations/20260609140242_add_certificate/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE `certificate` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `id_project` INTEGER NOT NULL, + `id_user` INTEGER NOT NULL, + `certificate_no` VARCHAR(255) NOT NULL, + `issued_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `certificate_certificate_no_key`(`certificate_no`), + INDEX `certificate_id_project_foreign`(`id_project`), + INDEX `certificate_id_user_foreign`(`id_user`), + UNIQUE INDEX `certificate_id_project_id_user_key`(`id_project`, `id_user`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `certificate` ADD CONSTRAINT `certificate_id_project_foreign` FOREIGN KEY (`id_project`) REFERENCES `project`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `certificate` ADD CONSTRAINT `certificate_id_user_foreign` FOREIGN KEY (`id_user`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e226758..f400dc7 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -194,6 +194,7 @@ model User { lamaran_magang LamaranMagang @relation(fields: [id_lamaran_magang], references: [id], map: "user_id_lamaran_magang_foreign") project_members ProjectMember[] task_submissions TaskSubmission[] + certificates Certificate[] @@index([email]) @@map("user") @@ -212,9 +213,10 @@ model Project { created_at DateTime @default(now()) updated_at DateTime @updatedAt - admin Admin @relation(fields: [id_admin], references: [id], map: "project_id_admin_foreign") - members ProjectMember[] - tasks Task[] + admin Admin @relation(fields: [id_admin], references: [id], map: "project_id_admin_foreign") + members ProjectMember[] + tasks Task[] + certificates Certificate[] @@index([id_admin], map: "project_id_admin_foreign") @@index([status]) @@ -328,3 +330,20 @@ enum SubmissionType { file_upload url_link } + +model Certificate { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) @db.VarChar(36) + id_project Int + id_user Int + certificate_no String @unique @db.VarChar(255) + issued_at DateTime @default(now()) + + project Project @relation(fields: [id_project], references: [id], map: "certificate_id_project_foreign") + user User @relation(fields: [id_user], references: [id], map: "certificate_id_user_foreign") + + @@unique([id_project, id_user]) + @@index([id_project], map: "certificate_id_project_foreign") + @@index([id_user], map: "certificate_id_user_foreign") + @@map("certificate") +} diff --git a/src/index.js b/src/index.js index 7831774..cf953b3 100755 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ const faqRoutes = require('./routes/faq.routes'); const feedbackRoutes = require('./routes/feedback.routes'); const batchRoutes = require('./routes/batch.routes'); const projectRoutes = require('./routes/project.routes'); +const certificateRoutes = require('./routes/certificate.routes'); const setupSwaggerDocs = require('./docs/swagger'); const PORT = process.env.PORT; @@ -56,6 +57,7 @@ app.use("/faq-api", faqRoutes); app.use("/feedback-api", feedbackRoutes); app.use("/batch-api", batchRoutes); app.use("/project-api", projectRoutes); +app.use("/certificate-api", certificateRoutes); // app.use("/project-member-api", projectMemberRoutes); // app.use("/task-api", taskRoutes); diff --git a/src/modules/certificate/controllers/certificate.controller.js b/src/modules/certificate/controllers/certificate.controller.js new file mode 100644 index 0000000..77f6d16 --- /dev/null +++ b/src/modules/certificate/controllers/certificate.controller.js @@ -0,0 +1,177 @@ +const certificateService = require('../services/certificate.service'); +const { response, error } = require('../../../helpers/utils/wrapper'); +const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code'); +const { InternalServerError } = require('../../../helpers/error'); +const { isValidPayload } = require('../../../helpers/utils/validator'); +const { claimCertificateModel } = require('../models/certificate.model'); + +const getActor = (req) => ({ + id: req.id, + role: req.role, + email: req.email, +}); + +class CertificateController { + async claimCertificate(req, res) { + try { + const validatePayload = isValidPayload(req.body, claimCertificateModel); + + if (validatePayload.err) { + return response( + res, + 'fail', + { err: validatePayload.err, data: null }, + 'Data claim certificate tidak valid', + ERROR.EXPECTATION_FAILED + ); + } + + const actor = getActor(req); + const result = await certificateService.claimCertificate(validatePayload.data, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Certificate claimed successfully', SUCCESS.CREATED); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getMyCertificates(req, res) { + try { + const actor = getActor(req); + const result = await certificateService.getMyCertificates(actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Certificates retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getCertificateDetail(req, res) { + try { + const { id } = req.params; + const actor = getActor(req); + const result = await certificateService.getCertificateDetail(id, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Certificate details retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getProjectCertificates(req, res) { + try { + const { id_project } = req.params; + const actor = getActor(req); + const result = await certificateService.getProjectCertificates(id_project, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Project certificates retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getAllCertificates(req, res) { + try { + const actor = getActor(req); + const result = await certificateService.getAllCertificates(actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'All certificates retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async verifyCertificate(req, res) { + try { + const certificate_no = req.query.certificate_no || req.params.certificate_no; + const result = await certificateService.verifyCertificate(certificate_no); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Certificate validated successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async verifyCertificateByUuid(req, res) { + try { + const { uuid } = req.params; + const result = await certificateService.verifyCertificateByUuid(uuid); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Certificate validated successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } +} + +module.exports = new CertificateController(); diff --git a/src/modules/certificate/index.js b/src/modules/certificate/index.js new file mode 100644 index 0000000..f898b95 --- /dev/null +++ b/src/modules/certificate/index.js @@ -0,0 +1,5 @@ +const certificateController = require('./controllers/certificate.controller'); + +module.exports = { + certificateController, +}; diff --git a/src/modules/certificate/models/certificate.model.js b/src/modules/certificate/models/certificate.model.js new file mode 100644 index 0000000..81d67b7 --- /dev/null +++ b/src/modules/certificate/models/certificate.model.js @@ -0,0 +1,13 @@ +const joi = require('joi'); + +const claimCertificateModel = joi.object().keys({ + id_project: joi.number().integer().required().messages({ + 'number.base': 'Project ID harus berupa angka.', + 'number.integer': 'Project ID harus berupa bilangan bulat.', + 'any.required': 'Project ID wajib diisi.', + }), +}); + +module.exports = { + claimCertificateModel, +}; diff --git a/src/modules/certificate/repositories/certificate.repository.js b/src/modules/certificate/repositories/certificate.repository.js new file mode 100644 index 0000000..51e2221 --- /dev/null +++ b/src/modules/certificate/repositories/certificate.repository.js @@ -0,0 +1,118 @@ +const prisma = require('../../../helpers/db/db_connection'); + +class CertificateRepository { + async create(certificateData) { + return prisma.certificate.create({ + data: certificateData, + include: this.certificateDetailInclude(), + }); + } + + async findById(id) { + return prisma.certificate.findUnique({ + where: { id: parseInt(id) }, + include: this.certificateDetailInclude(), + }); + } + + async findByUserAndProject(id_user, id_project) { + return prisma.certificate.findUnique({ + where: { + id_project_id_user: { + id_project: parseInt(id_project), + id_user: parseInt(id_user), + }, + }, + include: this.certificateDetailInclude(), + }); + } + + async findUserCertificates(id_user) { + return prisma.certificate.findMany({ + where: { id_user: parseInt(id_user) }, + include: this.certificateDetailInclude(), + orderBy: { issued_at: 'desc' }, + }); + } + + async findProjectCertificates(id_project) { + return prisma.certificate.findMany({ + where: { id_project: parseInt(id_project) }, + include: this.certificateDetailInclude(), + orderBy: { issued_at: 'desc' }, + }); + } + + async findAll() { + return prisma.certificate.findMany({ + include: this.certificateDetailInclude(), + orderBy: { issued_at: 'desc' }, + }); + } + + async findByCertificateNo(certificate_no) { + return prisma.certificate.findUnique({ + where: { certificate_no }, + include: this.certificateDetailInclude(), + }); + } + + async findByUuid(uuid) { + return prisma.certificate.findUnique({ + where: { uuid }, + include: this.certificateDetailInclude(), + }); + } + + async getProjectTasksAndSubmissions(id_project, id_user) { + return prisma.project.findUnique({ + where: { id: parseInt(id_project) }, + select: { + id: true, + project_name: true, + tasks: { + select: { + id: true, + submissions: { + where: { id_user: parseInt(id_user) }, + select: { + id: true, + submitted_at: true, + }, + }, + }, + }, + members: { + where: { + id_user: parseInt(id_user), + status: 'active', + }, + select: { + id: true, + }, + }, + }, + }); + } + + certificateDetailInclude() { + return { + user: { + select: { + id: true, + full_name: true, + email: true, + }, + }, + project: { + select: { + id: true, + project_name: true, + description: true, + }, + }, + }; + } +} + +module.exports = new CertificateRepository(); diff --git a/src/modules/certificate/services/certificate.service.js b/src/modules/certificate/services/certificate.service.js new file mode 100644 index 0000000..2487e5c --- /dev/null +++ b/src/modules/certificate/services/certificate.service.js @@ -0,0 +1,225 @@ +const certificateRepository = require('../repositories/certificate.repository'); +const { data, error } = require('../../../helpers/utils/wrapper'); +const { + BadRequestError, + NotFoundError, + ForbiddenError, + ConflictError, +} = require('../../../helpers/error'); + +class CertificateService { + async claimCertificate(payload, actor) { + try { + if (!actor?.id) { + return error(new BadRequestError('User ID is required')); + } + + if (actor.role !== 'intern') { + return error( + new ForbiddenError('Access denied: only interns can claim certificates'), + ); + } + + const { id_project } = payload; + + const existingCert = await certificateRepository.findByUserAndProject(actor.id, id_project); + if (existingCert) { + return error(new ConflictError('Certificate already claimed for this project')); + } + + const projectInfo = await certificateRepository.getProjectTasksAndSubmissions(id_project, actor.id); + + if (!projectInfo) { + return error(new NotFoundError('Project not found')); + } + + if (!projectInfo.members || projectInfo.members.length === 0) { + return error( + new ForbiddenError('Access denied: you are not an active member of this project'), + ); + } + + if (!projectInfo.tasks || projectInfo.tasks.length === 0) { + return error( + new BadRequestError('Cannot claim certificate: this project does not have any tasks assigned yet'), + ); + } + + const completedTasks = projectInfo.tasks.filter( + (task) => task.submissions && task.submissions.length > 0 + ); + + if (completedTasks.length < projectInfo.tasks.length) { + return error( + new BadRequestError( + `Cannot claim certificate: you have completed ${completedTasks.length} out of ${projectInfo.tasks.length} tasks` + ), + ); + } + + // Generate certificate number berdsarkan format ini: CERT/YYYYMMDD/PRJ{id_project}/USR{id_user} [Rafi 09/06/2026 20:45] + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const date = String(now.getDate()).padStart(2, '0'); + const dateString = `${year}${month}${date}`; + const certificateNo = `CERT/${dateString}/PRJ${id_project}/USR${actor.id}`; + + const certificateData = { + id_project: parseInt(id_project), + id_user: parseInt(actor.id), + certificate_no: certificateNo, + issued_at: now, + }; + + const certificate = await certificateRepository.create(certificateData); + + return data(this.mapCertificate(certificate)); + } catch (err) { + return error(err); + } + } + + async getMyCertificates(actor) { + try { + if (!actor?.id) { + return error(new BadRequestError('User ID is required')); + } + + if (actor.role !== 'intern') { + return error( + new ForbiddenError('Access denied: only interns can view their certificates'), + ); + } + + const certificates = await certificateRepository.findUserCertificates(actor.id); + return data(certificates.map((cert) => this.mapCertificate(cert))); + } catch (err) { + return error(err); + } + } + + async getCertificateDetail(id, actor) { + try { + if (!actor?.id) { + return error(new BadRequestError('User ID is required')); + } + + const certificate = await certificateRepository.findById(id); + if (!certificate) { + return error(new NotFoundError('Certificate not found')); + } + + if (actor.role === 'intern' && certificate.id_user !== actor.id) { + return error( + new ForbiddenError("Access denied: you cannot view other intern's certificates"), + ); + } + + return data(this.mapCertificate(certificate)); + } catch (err) { + return error(err); + } + } + + async getProjectCertificates(id_project, actor) { + try { + if (!actor?.id) { + return error(new BadRequestError('Admin ID is required')); + } + + if (actor.role !== 'admin') { + return error( + new ForbiddenError('Access denied: only admins/mentors can view project certificates'), + ); + } + + const certificates = await certificateRepository.findProjectCertificates(id_project); + return data(certificates.map((cert) => this.mapCertificate(cert))); + } catch (err) { + return error(err); + } + } + + async getAllCertificates(actor) { + try { + if (!actor?.id) { + return error(new BadRequestError('Admin ID is required')); + } + + if (actor.role !== 'admin') { + return error( + new ForbiddenError('Access denied: only admins/mentors can view all certificates'), + ); + } + + const certificates = await certificateRepository.findAll(); + return data(certificates.map((cert) => this.mapCertificate(cert))); + } catch (err) { + return error(err); + } + } + + async verifyCertificate(certificate_no) { + try { + if (!certificate_no) { + return error(new BadRequestError('Certificate number is required')); + } + + const certificate = await certificateRepository.findByCertificateNo(certificate_no); + if (!certificate) { + return error(new NotFoundError('Certificate not found or invalid')); + } + + return data(this.mapCertificate(certificate)); + } catch (err) { + return error(err); + } + } + + async verifyCertificateByUuid(uuid) { + try { + if (!uuid) { + return error(new BadRequestError('Certificate UUID is required')); + } + + const certificate = await certificateRepository.findByUuid(uuid); + if (!certificate) { + return error(new NotFoundError('Certificate not found or invalid')); + } + + return data({ + uuid: certificate.uuid, + intern_name: certificate.user ? certificate.user.full_name : null, + project_name: certificate.project ? certificate.project.project_name : null, + created_at: certificate.issued_at, + }); + } catch (err) { + return error(err); + } + } + + mapCertificate(cert) { + if (!cert) return null; + return { + id: cert.id, + uuid: cert.uuid, + id_project: cert.id_project, + id_user: cert.id_user, + certificate_no: cert.certificate_no, + issued_at: cert.issued_at, + user: cert.user ? { + id: cert.user.id, + full_name: cert.user.full_name, + email: cert.user.email, + } : null, + project: cert.project ? { + id: cert.project.id, + project_name: cert.project.project_name, + description: cert.project.description, + } : null, + }; + } +} + +module.exports = new CertificateService(); diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index d1775d1..2efe074 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -477,19 +477,16 @@ class ProjectService { return error(new BadRequestError("Project ID and User ID are required")); } - // Check if project exists const project = await projectRepository.findById(id_project); if (!project) { return error(new NotFoundError("Project not found")); } - // Check if user exists and is active const user = await projectRepository.findUserByIdAndActive(id_user); if (!user) { return error(new NotFoundError("User not found or inactive")); } - // Check if already a member of this project const existingMembership = await projectRepository.findMembership(id_project, id_user); let membership; @@ -497,10 +494,8 @@ class ProjectService { if (existingMembership.status === 'active') { return error(new ConflictError("User is already an active member of this project")); } - // Reactivate membership membership = await projectRepository.updateMembershipStatus(existingMembership.id, 'active'); } else { - // Create new membership membership = await projectRepository.assignMember(id_project, id_user); } diff --git a/src/routes/certificate.routes.js b/src/routes/certificate.routes.js new file mode 100644 index 0000000..fec42f2 --- /dev/null +++ b/src/routes/certificate.routes.js @@ -0,0 +1,369 @@ +const express = require('express'); +const { certificateController } = require('../modules/certificate'); +const { verifyJWT } = require('../middleware/verifyJWT'); +const isAdmin = require('../middleware/isAdmin'); + +const router = express.Router(); + +/** + * @swagger + * components: + * schemas: + * Certificate: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * id_project: + * type: integer + * example: 2 + * id_user: + * type: integer + * example: 5 + * certificate_no: + * type: string + * example: "CERT/20260609/PRJ2/USR5" + * issued_at: + * type: string + * format: date-time + * example: "2026-06-09T14:00:00.000Z" + * user: + * type: object + * properties: + * id: + * type: integer + * example: 5 + * full_name: + * type: string + * example: "Rafi Athallah" + * email: + * type: string + * example: "rafi@student.com" + * project: + * type: object + * properties: + * id: + * type: integer + * example: 2 + * project_name: + * type: string + * example: "Internify Platform Dev" + * description: + * type: string + * example: "Project to develop features of the Internify web app" + * CertificateClaimRequest: + * type: object + * required: + * - id_project + * properties: + * id_project: + * type: integer + * example: 2 + */ + +/** + * @swagger + * /certificate-api/claim: + * post: + * summary: Claim certificate for a completed project + * description: Interns can claim a certificate for a project once they have submitted all assigned tasks. + * tags: [Certificate] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CertificateClaimRequest' + * responses: + * 201: + * description: Certificate claimed successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/Certificate' + * message: + * type: string + * example: "Certificate claimed successfully" + * code: + * type: integer + * example: 201 + * 400: + * description: Bad request (no tasks assigned or not all tasks submitted) + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (only interns can claim, or not a member of project) + * 404: + * description: Project not found + * 409: + * description: Conflict (certificate already claimed) + * 500: + * description: Internal server error + */ +router.post('/claim', verifyJWT, certificateController.claimCertificate); + +/** + * @swagger + * /certificate-api/my-certificates: + * get: + * summary: Get logged-in intern's certificates + * description: Retrieve all certificates earned by the currently logged-in intern. + * tags: [Certificate] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Certificates retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: array + * items: + * $ref: '#/components/schemas/Certificate' + * message: + * type: string + * example: "Certificates retrieved successfully" + * code: + * type: integer + * example: 200 + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (only interns can access this) + * 500: + * description: Internal server error + */ +router.get('/my-certificates', verifyJWT, certificateController.getMyCertificates); + +/** + * @swagger + * /certificate-api/all: + * get: + * summary: Get all issued certificates (Admin only) + * description: Admin/Mentor can retrieve all certificates issued across the system. + * tags: [Certificate] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: All certificates retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: array + * items: + * $ref: '#/components/schemas/Certificate' + * message: + * type: string + * example: "All certificates retrieved successfully" + * code: + * type: integer + * example: 200 + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Admin only) + * 500: + * description: Internal server error + */ +router.get('/all', verifyJWT, isAdmin, certificateController.getAllCertificates); + +/** + * @swagger + * /certificate-api/detail/{id}: + * get: + * summary: Get certificate detail + * description: Fetch detailed information for a certificate by its ID. Interns can only access their own. Admins can access any. + * tags: [Certificate] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Certificate ID + * example: 1 + * responses: + * 200: + * description: Certificate details retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/Certificate' + * message: + * type: string + * example: "Certificate details retrieved successfully" + * code: + * type: integer + * example: 200 + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Accessing another user's certificate) + * 404: + * description: Certificate not found + * 500: + * description: Internal server error + */ +router.get('/detail/:id', verifyJWT, certificateController.getCertificateDetail); + +/** + * @swagger + * /certificate-api/project/{id_project}: + * get: + * summary: Get certificates issued for a specific project (Admin only) + * description: Admin/Mentor can retrieve all certificates issued to interns for a specific project. + * tags: [Certificate] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id_project + * schema: + * type: integer + * required: true + * description: Project ID + * example: 2 + * responses: + * 200: + * description: Project certificates retrieved successfully + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Admin only) + * 500: + * description: Internal server error + */ +router.get('/project/:id_project', verifyJWT, isAdmin, certificateController.getProjectCertificates); + +/** + * @swagger + * /certificate-api/verify: + * get: + * summary: Verify a certificate (Public) + * description: Verify the validity of a certificate number publicly without authentication. + * tags: [Certificate] + * parameters: + * - in: query + * name: certificate_no + * schema: + * type: string + * required: true + * description: The certificate number to verify + * example: "CERT/20260609/PRJ2/USR5" + * responses: + * 200: + * description: Certificate is valid + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/Certificate' + * message: + * type: string + * example: "Certificate validated successfully" + * code: + * type: integer + * example: 200 + * 400: + * description: Bad request (missing certificate number) + * 404: + * description: Certificate not found or invalid + * 500: + * description: Internal server error + */ +router.get('/verify', certificateController.verifyCertificate); + +/** + * @swagger + * /certificate-api/verify-uuid/{uuid}: + * get: + * summary: Verify a certificate by UUID (Public) + * description: Verify the validity of a certificate by its unique UUID for the frontend. Returns intern's name, creation date, and UUID. + * tags: [Certificate] + * parameters: + * - in: path + * name: uuid + * schema: + * type: string + * format: uuid + * required: true + * description: The certificate UUID to verify + * example: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6" + * responses: + * 200: + * description: Certificate is valid + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: object + * properties: + * uuid: + * type: string + * example: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6" + * intern_name: + * type: string + * example: "Rafi Athallah" + * project_name: + * type: string + * example: "Internify Platform Dev" + * created_at: + * type: string + * format: date-time + * example: "2026-06-09T14:00:00.000Z" + * message: + * type: string + * example: "Certificate validated successfully" + * code: + * type: integer + * example: 200 + * 400: + * description: Bad request (missing UUID) + * 404: + * description: Certificate not found or invalid + * 500: + * description: Internal server error + */ +router.get('/verify-uuid/:uuid', certificateController.verifyCertificateByUuid); + +module.exports = router; From 25f7d2369013d427ceb9fa438011eb9718d83c92 Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Wed, 10 Jun 2026 01:50:50 +0700 Subject: [PATCH 5/6] feat: menambahkan api untuk task project dan submission --- src/docs/swagger.js | 4 + src/index.js | 3 +- .../task/controllers/task.controller.js | 203 ++++++ src/modules/task/helpers/taskUpload.helper.js | 95 +++ src/modules/task/index.js | 5 + src/modules/task/models/task.model.js | 74 +++ .../task/repositories/task.repository.js | 159 +++++ src/modules/task/services/task.service.js | 441 +++++++++++++ src/routes/task.routes.js | 597 ++++++++++++++++++ 9 files changed, 1580 insertions(+), 1 deletion(-) create mode 100644 src/modules/task/controllers/task.controller.js create mode 100644 src/modules/task/helpers/taskUpload.helper.js create mode 100644 src/modules/task/models/task.model.js create mode 100644 src/modules/task/repositories/task.repository.js create mode 100644 src/modules/task/services/task.service.js diff --git a/src/docs/swagger.js b/src/docs/swagger.js index dc35ebc..f2815e1 100755 --- a/src/docs/swagger.js +++ b/src/docs/swagger.js @@ -67,6 +67,10 @@ const swaggerOptions = { { name: "Project", description: "Internify LMS project management endpoints" + }, + { + name: "Task", + description: "Managing project tasks and intern submissions" } ], components: { diff --git a/src/index.js b/src/index.js index 7831774..0431f2b 100755 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ const faqRoutes = require('./routes/faq.routes'); const feedbackRoutes = require('./routes/feedback.routes'); const batchRoutes = require('./routes/batch.routes'); const projectRoutes = require('./routes/project.routes'); +const taskRoutes = require('./routes/task.routes'); const setupSwaggerDocs = require('./docs/swagger'); const PORT = process.env.PORT; @@ -56,8 +57,8 @@ app.use("/faq-api", faqRoutes); app.use("/feedback-api", feedbackRoutes); app.use("/batch-api", batchRoutes); app.use("/project-api", projectRoutes); +app.use("/task-api", taskRoutes); // app.use("/project-member-api", projectMemberRoutes); -// app.use("/task-api", taskRoutes); setupSwaggerDocs(app); diff --git a/src/modules/task/controllers/task.controller.js b/src/modules/task/controllers/task.controller.js new file mode 100644 index 0000000..712568a --- /dev/null +++ b/src/modules/task/controllers/task.controller.js @@ -0,0 +1,203 @@ +const fs = require('fs'); +const taskService = require('../services/task.service'); +const { response, error } = require('../../../helpers/utils/wrapper'); +const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code'); +const { InternalServerError } = require('../../../helpers/error'); +const { isValidPayload } = require('../../../helpers/utils/validator'); +const { + createTaskModel, + updateTaskModel, + submitTaskModel, +} = require('../models/task.model'); + +const getActor = (req) => ({ + id: req.id, + role: req.role, + email: req.email, +}); + +const cleanupUploadedFile = (file) => { + if (file?.path && fs.existsSync(file.path)) { + fs.unlinkSync(file.path); + } +}; + +class TaskController { + async createTask(req, res) { + try { + const validatePayload = isValidPayload(req.body, createTaskModel); + + if (validatePayload.err) { + return response( + res, + 'fail', + { err: validatePayload.err, data: null }, + 'Invalid task data', + ERROR.EXPECTATION_FAILED + ); + } + + const actor = getActor(req); + const result = await taskService.createTask( + req.params.id_project, + validatePayload.data, + actor + ); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Task created successfully', SUCCESS.CREATED); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getTasksByProject(req, res) { + try { + const actor = getActor(req); + const result = await taskService.getTasksByProject(req.params.id_project, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Tasks retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getTaskById(req, res) { + try { + const actor = getActor(req); + const result = await taskService.getTaskById(req.params.id, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Task detail retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async updateTask(req, res) { + try { + const validatePayload = isValidPayload(req.body, updateTaskModel); + + if (validatePayload.err) { + return response( + res, + 'fail', + { err: validatePayload.err, data: null }, + 'Invalid task data', + ERROR.EXPECTATION_FAILED + ); + } + + const actor = getActor(req); + const result = await taskService.updateTask(req.params.id, validatePayload.data, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Task updated successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async deleteTask(req, res) { + try { + const actor = getActor(req); + const result = await taskService.deleteTask(req.params.id, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Task deleted successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async submitTask(req, res) { + try { + const validatePayload = isValidPayload(req.body, submitTaskModel); + + if (validatePayload.err) { + cleanupUploadedFile(req.file); + + return response( + res, + 'fail', + { err: validatePayload.err, data: null }, + 'Invalid submission data', + ERROR.EXPECTATION_FAILED + ); + } + + const actor = getActor(req); + const result = await taskService.submitTask( + req.params.id, + validatePayload.data, + req.file, + actor + ); + + if (result.err) { + cleanupUploadedFile(req.file); + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Task submitted successfully', SUCCESS.OK); + } catch (err) { + cleanupUploadedFile(req.file); + + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } +} + +module.exports = new TaskController(); diff --git a/src/modules/task/helpers/taskUpload.helper.js b/src/modules/task/helpers/taskUpload.helper.js new file mode 100644 index 0000000..97b559d --- /dev/null +++ b/src/modules/task/helpers/taskUpload.helper.js @@ -0,0 +1,95 @@ +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); + +const baseUploadDir = process.env.NODE_ENV === 'production' + ? '/tmp/uploads' + : path.join(__dirname, '../../uploads'); + +const taskUploadDir = path.join(baseUploadDir, 'task-submissions'); + +if (!fs.existsSync(taskUploadDir)) { + fs.mkdirSync(taskUploadDir, { recursive: true }); +} + +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, taskUploadDir), + filename: (_req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, uniqueSuffix + path.extname(file.originalname)); + }, +}); + +const allowedMimeTypes = [ + 'application/pdf', + // 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + // 'application/zip', + // 'application/x-zip-compressed', +]; + +const fileFilter = (req, file, cb) => { + if (allowedMimeTypes.includes(file.mimetype)) { + cb(null, true); + return; + } + + // const errMsg = 'Only PDF, DOCX, or ZIP files are allowed'; + const errMsg = 'Only PDF files are allowed'; + req.fileValidationError = errMsg; + cb(new Error(errMsg), false); +}; + +const taskUpload = multer({ + storage, + fileFilter, + limits: { + fileSize: 2 * 1024 * 1024, //2MB + }, +}); + +const taskUploadErrorHandler = (err, _req, res, next) => { + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ + status: false, + data: null, + message: 'File size is too large. Maximum size is 2MB', + code: 413, + }); + } + + if (err.code === 'LIMIT_UNEXPECTED_FILE') { + return res.status(400).json({ + status: false, + data: null, + message: { + [err.field]: `Field '${err.field}' cannot have more than 1 file.`, + }, + code: 400, + }); + } + + return res.status(400).json({ + status: false, + data: null, + message: err.message, + code: 400, + }); + } + + if (err) { + return res.status(400).json({ + status: false, + data: null, + message: err.message || 'Error while uploading file', + code: 400, + }); + } + + next(); +}; + +module.exports = { + taskUpload, + taskUploadErrorHandler, +}; diff --git a/src/modules/task/index.js b/src/modules/task/index.js index e69de29..e58623e 100644 --- a/src/modules/task/index.js +++ b/src/modules/task/index.js @@ -0,0 +1,5 @@ +const taskController = require('./controllers/task.controller'); + +module.exports = { + taskController +}; diff --git a/src/modules/task/models/task.model.js b/src/modules/task/models/task.model.js new file mode 100644 index 0000000..9961d12 --- /dev/null +++ b/src/modules/task/models/task.model.js @@ -0,0 +1,74 @@ +const joi = require('joi'); + +const SUBMISSION_TYPE_VALUES = ['file_upload', 'url_link']; + +const createTaskModel = joi.object().keys({ + title: joi.string().max(255).required().messages({ + 'string.base': 'Task title must be a string.', + 'string.empty': 'Task title cannot be empty.', + 'string.max': 'Task title must not exceed {#limit} characters.', + 'any.required': 'Task title is required.', + }), + + description: joi.string().required().messages({ + 'string.base': 'Task description must be a string.', + 'string.empty': 'Task description cannot be empty.', + 'any.required': 'Task description is required.', + }), + + deadline_date: joi.date().required().messages({ + 'date.base': 'Deadline date must be a valid date.', + 'any.required': 'Deadline date is required.', + }), + + specific_time: joi.string().pattern(/^([01]\d|2[0-3]):[0-5]\d$/).required().messages({ + 'string.base': 'Specific time must be a string.', + 'string.empty': 'Specific time cannot be empty.', + 'string.pattern.base': 'Specific time must use HH:mm format.', + 'any.required': 'Specific time is required.', + }), + + submission_type: joi.string().valid(...SUBMISSION_TYPE_VALUES).required().messages({ + 'string.base': 'Submission type must be a string.', + 'any.only': `Submission type must be one of: ${SUBMISSION_TYPE_VALUES.join(', ')}.`, + 'any.required': 'Submission type is required.', + }), +}); + +const updateTaskModel = joi.object().keys({ + title: joi.string().max(255).optional().messages({ + 'string.base': 'Task title must be a string.', + 'string.max': 'Task title must not exceed {#limit} characters.', + }), + + description: joi.string().optional().messages({ + 'string.base': 'Task description must be a string.', + }), + + deadline_date: joi.date().optional().messages({ + 'date.base': 'Deadline date must be a valid date.', + }), + + specific_time: joi.string().pattern(/^([01]\d|2[0-3]):[0-5]\d$/).optional().messages({ + 'string.base': 'Specific time must be a string.', + 'string.pattern.base': 'Specific time must use HH:mm format.', + }), + + submission_type: joi.string().valid(...SUBMISSION_TYPE_VALUES).optional().messages({ + 'string.base': 'Submission type must be a string.', + 'any.only': `Submission type must be one of: ${SUBMISSION_TYPE_VALUES.join(', ')}.`, + }), +}); + +const submitTaskModel = joi.object().keys({ + url_link: joi.string().uri().optional().messages({ + 'string.base': 'URL link must be a string.', + 'string.uri': 'URL link must be a valid URL.', + }), +}); + +module.exports = { + createTaskModel, + updateTaskModel, + submitTaskModel, +}; diff --git a/src/modules/task/repositories/task.repository.js b/src/modules/task/repositories/task.repository.js new file mode 100644 index 0000000..651994a --- /dev/null +++ b/src/modules/task/repositories/task.repository.js @@ -0,0 +1,159 @@ +const prisma = require('../../../helpers/db/db_connection'); + +class TaskRepository { + async findProjectById(idProject) { + return prisma.project.findUnique({ + where: { + id: parseInt(idProject), + }, + include: { + members: { + where: { + status: 'active', + }, + select: { + id: true, + id_user: true, + }, + }, + }, + }); + } + + async createTask(taskData) { + return prisma.task.create({ + data: taskData, + }); + } + + async findTasksByProject(idProject) { + return prisma.task.findMany({ + where: { + id_project: parseInt(idProject), + }, + orderBy: { + deadline_at: 'asc', + }, + include: { + submissions: { + select: { + id: true, + id_task: true, + id_user: true, + file_path: true, + url_link: true, + submitted_at: true, + updated_at: true, + }, + }, + }, + }); + } + + async findTaskById(idTask) { + return prisma.task.findUnique({ + where: { + id: parseInt(idTask), + }, + include: { + project: { + select: { + id: true, + project_name: true, + project_icon: true, + members: { + where: { + status: 'active', + }, + include: { + user: { + select: { + id: true, + full_name: true, + email: true, + professional_bio: true, + }, + }, + }, + }, + }, + }, + submissions: { + include: { + user: { + select: { + id: true, + full_name: true, + email: true, + professional_bio: true, + }, + }, + }, + }, + }, + }); + } + + async updateTask(idTask, taskData) { + return prisma.task.update({ + where: { + id: parseInt(idTask), + }, + data: taskData, + }); + } + + async deleteTask(idTask) { + return prisma.task.delete({ + where: { + id: parseInt(idTask), + }, + }); + } + + async countSubmissionsByTask(idTask) { + return prisma.taskSubmission.count({ + where: { + id_task: parseInt(idTask), + }, + }); + } + + async checkActiveProjectMember(idProject, idUser) { + return prisma.projectMember.findFirst({ + where: { + id_project: parseInt(idProject), + id_user: parseInt(idUser), + status: 'active', + }, + }); + } + + async findSubmissionByTaskAndUser(idTask, idUser) { + return prisma.taskSubmission.findUnique({ + where: { + id_task_id_user: { + id_task: parseInt(idTask), + id_user: parseInt(idUser), + }, + }, + }); + } + + async createSubmission(submissionData) { + return prisma.taskSubmission.create({ + data: submissionData, + }); + } + + async updateSubmission(idSubmission, submissionData) { + return prisma.taskSubmission.update({ + where: { + id: parseInt(idSubmission), + }, + data: submissionData, + }); + } +} + +module.exports = new TaskRepository(); diff --git a/src/modules/task/services/task.service.js b/src/modules/task/services/task.service.js new file mode 100644 index 0000000..de9f41a --- /dev/null +++ b/src/modules/task/services/task.service.js @@ -0,0 +1,441 @@ +const taskRepository = require('../repositories/task.repository'); +const { data, error } = require('../../../helpers/utils/wrapper'); +const { + BadRequestError, + NotFoundError, + ForbiddenError, + ConflictError, +} = require('../../../helpers/error'); + +class TaskService { + async createTask(idProject, payload, actor = {}) { + try { + if (!this.isPositiveInteger(idProject)) { + return error(new BadRequestError('Project ID must be a valid number')); + } + + if (!actor?.id || actor.role !== 'admin') { + return error(new ForbiddenError('Access denied: only admin can create task')); + } + + const project = await taskRepository.findProjectById(idProject); + + if (!project) { + return error(new NotFoundError('Project not found')); + } + + const deadlineAt = this.buildDeadlineAt(payload.deadline_date, payload.specific_time); + + const task = await taskRepository.createTask({ + id_project: parseInt(idProject), + title: payload.title, + description: payload.description, + deadline_at: deadlineAt, + submission_type: payload.submission_type, + }); + + return data(this.mapTask(task)); + } catch (err) { + return error(err); + } + } + + async getTasksByProject(idProject, actor = {}) { + try { + if (!this.isPositiveInteger(idProject)) { + return error(new BadRequestError('Project ID must be a valid number')); + } + + const project = await taskRepository.findProjectById(idProject); + + if (!project) { + return error(new NotFoundError('Project not found')); + } + + const accessError = await this.validateProjectAccess(idProject, actor); + + if (accessError) { + return error(accessError); + } + + const tasks = await taskRepository.findTasksByProject(idProject); + const totalMembers = project.members?.length || 0; + + const mappedTasks = tasks.map((task) => { + if (actor.role === 'intern') { + const mySubmission = task.submissions.find( + (submission) => submission.id_user === parseInt(actor.id) + ) || null; + + return { + ...this.mapTask(task), + display_status: this.getDisplayStatus(task.deadline_at, mySubmission), + my_submission: mySubmission ? this.mapSubmission(mySubmission) : null, + }; + } + + return { + ...this.mapTask(task), + total_members: totalMembers, + total_submissions: task.submissions?.length || 0, + }; + }); + + return data(mappedTasks); + } catch (err) { + return error(err); + } + } + + async getTaskById(idTask, actor = {}) { + try { + if (!this.isPositiveInteger(idTask)) { + return error(new BadRequestError('Task ID must be a valid number')); + } + + const task = await taskRepository.findTaskById(idTask); + + if (!task) { + return error(new NotFoundError('Task not found')); + } + + const accessError = await this.validateProjectAccess(task.id_project, actor); + + if (accessError) { + return error(accessError); + } + + if (actor.role === 'admin') { + return data(this.mapAdminTaskDetail(task)); + } + + const mySubmission = task.submissions.find( + (submission) => submission.id_user === parseInt(actor.id) + ) || null; + + return data(this.mapInternTaskDetail(task, mySubmission)); + } catch (err) { + return error(err); + } + } + + async updateTask(idTask, payload, actor = {}) { + try { + if (!this.isPositiveInteger(idTask)) { + return error(new BadRequestError('Task ID must be a valid number')); + } + + if (!actor?.id || actor.role !== 'admin') { + return error(new ForbiddenError('Access denied: only admin can update task')); + } + + if (Object.keys(payload).length === 0) { + return error(new BadRequestError('At least one field is required to update task')); + } + + const existingTask = await taskRepository.findTaskById(idTask); + + if (!existingTask) { + return error(new NotFoundError('Task not found')); + } + + const submissionCount = await taskRepository.countSubmissionsByTask(idTask); + + if ( + payload.submission_type && + payload.submission_type !== existingTask.submission_type && + submissionCount > 0 + ) { + return error( + new ConflictError('Submission type cannot be changed because this task already has submissions') + ); + } + + const updateData = {}; + + if (payload.title !== undefined) updateData.title = payload.title; + if (payload.description !== undefined) updateData.description = payload.description; + if (payload.submission_type !== undefined) updateData.submission_type = payload.submission_type; + + if (payload.deadline_date !== undefined || payload.specific_time !== undefined) { + updateData.deadline_at = this.buildDeadlineAtFromExisting( + existingTask.deadline_at, + payload.deadline_date, + payload.specific_time + ); + } + + const updatedTask = await taskRepository.updateTask(idTask, updateData); + + return data(this.mapTask(updatedTask)); + } catch (err) { + return error(err); + } + } + + async deleteTask(idTask, actor = {}) { + try { + if (!this.isPositiveInteger(idTask)) { + return error(new BadRequestError('Task ID must be a valid number')); + } + + if (!actor?.id || actor.role !== 'admin') { + return error(new ForbiddenError('Access denied: only admin can delete task')); + } + + const task = await taskRepository.findTaskById(idTask); + + if (!task) { + return error(new NotFoundError('Task not found')); + } + + const submissionCount = await taskRepository.countSubmissionsByTask(idTask); + + if (submissionCount > 0) { + return error(new ConflictError('Task already has submissions and cannot be deleted')); + } + + await taskRepository.deleteTask(idTask); + + return data(null); + } catch (err) { + return error(err); + } + } + + async submitTask(idTask, payload, file, actor = {}) { + try { + if (!this.isPositiveInteger(idTask)) { + return error(new BadRequestError('Task ID must be a valid number')); + } + + if (!actor?.id || actor.role !== 'intern') { + return error(new ForbiddenError('Access denied: only intern can submit task')); + } + + const task = await taskRepository.findTaskById(idTask); + + if (!task) { + return error(new NotFoundError('Task not found')); + } + + const membership = await taskRepository.checkActiveProjectMember(task.id_project, actor.id); + + if (!membership) { + return error(new ForbiddenError('Access denied: you are not a member of this project')); + } + + const submissionData = { + id_task: parseInt(idTask), + id_user: parseInt(actor.id), + }; + + if (task.submission_type === 'file_upload') { + if (!file) { + return error(new BadRequestError('Submission file is required for this task')); + } + + if (payload.url_link) { + return error(new BadRequestError('URL link is not allowed for file upload task')); + } + + submissionData.file_path = `/uploads/task-submissions/${file.filename}`; + submissionData.url_link = null; + } + + if (task.submission_type === 'url_link') { + if (file) { + return error(new BadRequestError('File upload is not allowed for URL link task')); + } + + if (!payload.url_link) { + return error(new BadRequestError('URL link is required for this task')); + } + + submissionData.file_path = null; + submissionData.url_link = payload.url_link; + } + + const existingSubmission = await taskRepository.findSubmissionByTaskAndUser(idTask, actor.id); + + let submission; + + if (existingSubmission) { + submission = await taskRepository.updateSubmission(existingSubmission.id, submissionData); + } else { + submission = await taskRepository.createSubmission(submissionData); + } + + return data(this.mapSubmission(submission)); + } catch (err) { + return error(err); + } + } + + async validateProjectAccess(idProject, actor = {}) { + if (!actor?.id || !actor?.role) { + return new ForbiddenError('Access denied: authentication data is missing'); + } + + if (actor.role === 'admin') { + return null; + } + + if (actor.role === 'intern') { + const membership = await taskRepository.checkActiveProjectMember(idProject, actor.id); + + if (!membership) { + return new ForbiddenError('Access denied: you are not a member of this project'); + } + + return null; + } + + return new ForbiddenError('Access denied: invalid role'); + } + + buildDeadlineAt(deadlineDate, specificTime) { + const dateOnly = this.formatDateOnly(new Date(deadlineDate)); + return new Date(`${dateOnly}T${specificTime}:00`); + } + + buildDeadlineAtFromExisting(existingDeadlineAt, deadlineDate, specificTime) { + const currentDate = this.formatDateOnly(new Date(existingDeadlineAt)); + const currentTime = this.formatTimeOnly(new Date(existingDeadlineAt)); + + const nextDate = deadlineDate !== undefined + ? this.formatDateOnly(new Date(deadlineDate)) + : currentDate; + + const nextTime = specificTime !== undefined + ? specificTime + : currentTime; + + return new Date(`${nextDate}T${nextTime}:00`); + } + + formatDateOnly(date) { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + + return `${year}-${month}-${day}`; + } + + formatTimeOnly(date) { + const hours = `${date.getHours()}`.padStart(2, '0'); + const minutes = `${date.getMinutes()}`.padStart(2, '0'); + + return `${hours}:${minutes}`; + } + + getDisplayStatus(deadlineAt, submission) { + const now = new Date(); + const deadline = new Date(deadlineAt); + + if (deadline < now) { + return 'overdue'; + } + + if (submission) { + return 'done'; + } + + return 'pending'; + } + + mapTask(task) { + if (!task) return null; + + return { + id: task.id, + id_project: task.id_project, + title: task.title, + description: task.description, + deadline_at: task.deadline_at, + submission_type: task.submission_type, + created_at: task.created_at, + updated_at: task.updated_at, + }; + } + + mapSubmission(submission) { + if (!submission) return null; + + return { + id: submission.id, + id_task: submission.id_task, + id_user: submission.id_user, + file_path: submission.file_path, + url_link: submission.url_link, + submitted_at: submission.submitted_at, + updated_at: submission.updated_at, + }; + } + + mapInternTaskDetail(task, submission) { + return { + ...this.mapTask(task), + project: { + id: task.project?.id, + project_name: task.project?.project_name, + project_icon: task.project?.project_icon, + }, + display_status: this.getDisplayStatus(task.deadline_at, submission), + my_submission: submission ? this.mapSubmission(submission) : null, + }; + } + + mapAdminTaskDetail(task) { + const members = task.project?.members || []; + const submissions = task.submissions || []; + + const mappedSubmissions = members.map((member) => { + const user = member.user; + const submission = submissions.find( + (item) => item.id_user === user.id + ) || null; + + const displayStatus = this.getDisplayStatus(task.deadline_at, submission); + + return { + id_user: user.id, + full_name: user.full_name, + email: user.email, + profile_picture: null, + professional_bio: user.professional_bio || null, + submitted_at: submission?.submitted_at || null, + display_status: displayStatus, + submission: submission ? this.mapSubmission(submission) : null, + }; + }); + + const totalDone = mappedSubmissions.filter((item) => item.display_status === 'done').length; + const totalPending = mappedSubmissions.filter((item) => item.display_status === 'pending').length; + const totalOverdue = mappedSubmissions.filter((item) => item.display_status === 'overdue').length; + + return { + ...this.mapTask(task), + project: { + id: task.project?.id, + project_name: task.project?.project_name, + project_icon: task.project?.project_icon, + }, + submission_summary: { + total_members: members.length, + total_submitted: submissions.length, + total_done: totalDone, + total_pending: totalPending, + total_overdue: totalOverdue, + }, + submissions: mappedSubmissions, + }; + } + + isPositiveInteger(value) { + const number = Number(value); + return Number.isInteger(number) && number > 0; + } +} + +module.exports = new TaskService(); diff --git a/src/routes/task.routes.js b/src/routes/task.routes.js index e69de29..3f8abc7 100644 --- a/src/routes/task.routes.js +++ b/src/routes/task.routes.js @@ -0,0 +1,597 @@ +const express = require('express'); +const { taskController } = require('../modules/task'); +const { verifyJWT } = require('../middleware/verifyJWT'); +const isAdmin = require('../middleware/isAdmin'); +const { + taskUpload, + taskUploadErrorHandler, +} = require('../modules/task/helpers/taskUpload.helper'); + +const router = express.Router(); + +/** + * @swagger + * components: + * schemas: + * Task: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * id_project: + * type: integer + * example: 1 + * title: + * type: string + * example: Laporan Tugas 2 + * description: + * type: string + * example: Berisi design UI/UX dan user flow dari aplikasi Internify. + * deadline_at: + * type: string + * format: date-time + * example: 2026-05-28T23:59:00.000Z + * submission_type: + * type: string + * enum: [file_upload, url_link] + * example: file_upload + * created_at: + * type: string + * format: date-time + * example: 2026-06-09T10:00:00.000Z + * updated_at: + * type: string + * format: date-time + * example: 2026-06-09T10:00:00.000Z + * + * TaskCreateRequest: + * type: object + * required: + * - title + * - description + * - deadline_date + * - specific_time + * - submission_type + * properties: + * title: + * type: string + * example: Laporan Tugas 2 + * description: + * type: string + * example: Berisi design UI/UX dan user flow dari aplikasi Internify. + * deadline_date: + * type: string + * format: date + * example: 2026-05-28 + * specific_time: + * type: string + * example: "23:59" + * submission_type: + * type: string + * enum: [file_upload, url_link] + * example: file_upload + * + * TaskUpdateRequest: + * type: object + * properties: + * title: + * type: string + * example: Laporan Tugas 2 Updated + * description: + * type: string + * example: Updated task description. + * deadline_date: + * type: string + * format: date + * example: 2026-05-30 + * specific_time: + * type: string + * example: "23:59" + * submission_type: + * type: string + * enum: [file_upload, url_link] + * example: url_link + * + * TaskSubmission: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * id_task: + * type: integer + * example: 1 + * id_user: + * type: integer + * example: 2 + * file_path: + * type: string + * nullable: true + * example: /uploads/task-submissions/report.pdf + * url_link: + * type: string + * nullable: true + * example: https://drive.google.com/file/example + * submitted_at: + * type: string + * format: date-time + * example: 2026-06-09T10:00:00.000Z + * updated_at: + * type: string + * format: date-time + * example: 2026-06-09T10:00:00.000Z + * + * InternTaskItem: + * allOf: + * - $ref: '#/components/schemas/Task' + * - type: object + * properties: + * display_status: + * type: string + * enum: [pending, done, overdue] + * example: pending + * my_submission: + * nullable: true + * $ref: '#/components/schemas/TaskSubmission' + * + * AdminTaskItem: + * allOf: + * - $ref: '#/components/schemas/Task' + * - type: object + * properties: + * total_members: + * type: integer + * example: 8 + * total_submissions: + * type: integer + * example: 5 + * + * AdminTaskSubmissionItem: + * type: object + * properties: + * id_user: + * type: integer + * example: 1 + * full_name: + * type: string + * example: Alex Rivera + * email: + * type: string + * example: alex.rivera@example.com + * profile_picture: + * type: string + * nullable: true + * example: null + * professional_bio: + * type: string + * nullable: true + * example: Backend developer intern + * submitted_at: + * type: string + * nullable: true + * format: date-time + * example: 2026-05-25T10:00:00.000Z + * display_status: + * type: string + * enum: [pending, done, overdue] + * example: done + * submission: + * nullable: true + * $ref: '#/components/schemas/TaskSubmission' + * + * AdminTaskDetail: + * allOf: + * - $ref: '#/components/schemas/Task' + * - type: object + * properties: + * project: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * project_name: + * type: string + * example: Internify Project + * project_icon: + * type: string + * example: code + * submission_summary: + * type: object + * properties: + * total_members: + * type: integer + * example: 8 + * total_submitted: + * type: integer + * example: 5 + * total_done: + * type: integer + * example: 4 + * total_pending: + * type: integer + * example: 3 + * total_overdue: + * type: integer + * example: 1 + * submissions: + * type: array + * items: + * $ref: '#/components/schemas/AdminTaskSubmissionItem' + * + * InternTaskDetail: + * allOf: + * - $ref: '#/components/schemas/Task' + * - type: object + * properties: + * project: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * project_name: + * type: string + * example: Internify Project + * project_icon: + * type: string + * example: code + * display_status: + * type: string + * enum: [pending, done, overdue] + * example: pending + * my_submission: + * nullable: true + * $ref: '#/components/schemas/TaskSubmission' + * + * TaskResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/Task' + * message: + * type: string + * example: Task created successfully + * code: + * type: integer + * example: 201 + * + * TaskListResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: array + * items: + * $ref: '#/components/schemas/Task' + * message: + * type: string + * example: Tasks retrieved successfully + * code: + * type: integer + * example: 200 + * + * AdminTaskDetailResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/AdminTaskDetail' + * message: + * type: string + * example: Task detail retrieved successfully + * code: + * type: integer + * example: 200 + * + * InternTaskDetailResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/InternTaskDetail' + * message: + * type: string + * example: Task detail retrieved successfully + * code: + * type: integer + * example: 200 + * + * TaskSubmissionResponse: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/TaskSubmission' + * message: + * type: string + * example: Task submitted successfully + * code: + * type: integer + * example: 200 + * + * ErrorResponse: + * type: object + * properties: + * status: + * type: boolean + * example: false + * data: + * nullable: true + * example: null + * message: + * type: string + * example: Validation error + * code: + * type: integer + * example: 417 + */ + +/** + * @swagger + * /task-api/projects/{id_project}/tasks: + * post: + * summary: Create task in project + * description: Create a new task inside a project. Only admin can access this endpoint. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id_project + * required: true + * schema: + * type: integer + * description: Project ID + * example: 1 + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskCreateRequest' + * responses: + * 201: + * description: Task created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskResponse' + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Project not found + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.post('/projects/:id_project/tasks', verifyJWT, isAdmin, taskController.createTask); + +/** + * @swagger + * /task-api/projects/{id_project}/tasks: + * get: + * summary: Get project tasks + * description: Get all tasks from a project. Admin can access any project task list. Intern can only access tasks from their active project. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id_project + * required: true + * schema: + * type: integer + * description: Project ID + * example: 1 + * responses: + * 200: + * description: Tasks retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskListResponse' + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Project not found + * 500: + * description: Internal server error + */ +router.get('/projects/:id_project/tasks', verifyJWT, taskController.getTasksByProject); + +/** + * @swagger + * /task-api/tasks/{id}: + * get: + * summary: Get task detail + * description: Get task detail. Admin receives all intern submissions. Intern receives only their own submission. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Task ID + * example: 1 + * responses: + * 200: + * description: Task detail retrieved successfully + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Task not found + * 500: + * description: Internal server error + */ +router.get('/tasks/:id', verifyJWT, taskController.getTaskById); + +/** + * @swagger + * /task-api/tasks/{id}: + * patch: + * summary: Update task + * description: Update task information. Submission type cannot be changed if the task already has submissions. Only admin can access this endpoint. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Task ID + * example: 1 + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskUpdateRequest' + * responses: + * 200: + * description: Task updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskResponse' + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Task not found + * 409: + * description: Submission type cannot be changed because this task already has submissions + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.patch('/tasks/:id', verifyJWT, isAdmin, taskController.updateTask); + +/** + * @swagger + * /task-api/tasks/{id}: + * delete: + * summary: Delete task + * description: Delete task only if it does not have submissions. Only admin can access this endpoint. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Task ID + * example: 1 + * responses: + * 200: + * description: Task deleted successfully + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Task not found + * 409: + * description: Task already has submissions and cannot be deleted + * 500: + * description: Internal server error + */ +router.delete('/tasks/:id', verifyJWT, isAdmin, taskController.deleteTask); + +/** + * @swagger + * /task-api/tasks/{id}/submissions: + * post: + * summary: Submit task + * description: Submit a task as an intern. For file_upload tasks, send submission_file. For url_link tasks, send url_link. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Task ID + * example: 1 + * requestBody: + * required: false + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * submission_file: + * type: string + * format: binary + * description: Required when task submission_type is file_upload. Allowed file types are PDF. Max size is 10MB. + * url_link: + * type: string + * format: uri + * description: Required when task submission_type is url_link. + * application/json: + * schema: + * type: object + * properties: + * url_link: + * type: string + * format: uri + * example: https://drive.google.com/file/example + * responses: + * 200: + * description: Task submitted successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskSubmissionResponse' + * 400: + * description: Bad request + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Task not found + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.post( + '/tasks/:id/submissions', + verifyJWT, + taskUpload.single('submission_file'), + taskUploadErrorHandler, + taskController.submitTask +); + +module.exports = router; From 662e9abc5147e6a3bf704e28a15cae5fe2b961ff Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Wed, 10 Jun 2026 02:56:24 +0700 Subject: [PATCH 6/6] feat: menambahkan API update dan delete submission --- .../task/controllers/task.controller.js | 65 +++++++++ src/modules/task/helpers/taskUpload.helper.js | 7 +- src/modules/task/models/task.model.js | 8 ++ .../task/repositories/task.repository.js | 40 ++++++ src/modules/task/services/task.service.js | 134 ++++++++++++++++++ src/routes/task.routes.js | 120 ++++++++++++++++ 6 files changed, 371 insertions(+), 3 deletions(-) diff --git a/src/modules/task/controllers/task.controller.js b/src/modules/task/controllers/task.controller.js index 712568a..58e04a8 100644 --- a/src/modules/task/controllers/task.controller.js +++ b/src/modules/task/controllers/task.controller.js @@ -8,6 +8,7 @@ const { createTaskModel, updateTaskModel, submitTaskModel, + updateSubmissionModel, } = require('../models/task.model'); const getActor = (req) => ({ @@ -198,6 +199,70 @@ class TaskController { ); } } + + async updateSubmission(req, res) { + try { + const validatePayload = isValidPayload(req.body, updateSubmissionModel); + + if (validatePayload.err) { + cleanupUploadedFile(req.file); + + return response( + res, + 'fail', + { err: validatePayload.err, data: null }, + 'Invalid submission data', + ERROR.EXPECTATION_FAILED + ); + } + + const actor = getActor(req); + const result = await taskService.updateSubmission( + req.params.id, + validatePayload.data, + req.file, + actor + ); + + if (result.err) { + cleanupUploadedFile(req.file); + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Submission updated successfully', SUCCESS.OK); + } catch (err) { + cleanupUploadedFile(req.file); + + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async deleteSubmission(req, res) { + try { + const actor = getActor(req); + const result = await taskService.deleteSubmission(req.params.id, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Submission deleted successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } } module.exports = new TaskController(); diff --git a/src/modules/task/helpers/taskUpload.helper.js b/src/modules/task/helpers/taskUpload.helper.js index 97b559d..4f6468b 100644 --- a/src/modules/task/helpers/taskUpload.helper.js +++ b/src/modules/task/helpers/taskUpload.helper.js @@ -4,7 +4,7 @@ const fs = require('fs'); const baseUploadDir = process.env.NODE_ENV === 'production' ? '/tmp/uploads' - : path.join(__dirname, '../../uploads'); + : path.join(__dirname, '../../../uploads'); const taskUploadDir = path.join(baseUploadDir, 'task-submissions'); @@ -43,7 +43,7 @@ const taskUpload = multer({ storage, fileFilter, limits: { - fileSize: 2 * 1024 * 1024, //2MB + fileSize: 10 * 1024 * 1024, //10MB }, }); @@ -53,7 +53,7 @@ const taskUploadErrorHandler = (err, _req, res, next) => { return res.status(413).json({ status: false, data: null, - message: 'File size is too large. Maximum size is 2MB', + message: 'File size is too large. Maximum size is 10MB', code: 413, }); } @@ -92,4 +92,5 @@ const taskUploadErrorHandler = (err, _req, res, next) => { module.exports = { taskUpload, taskUploadErrorHandler, + taskUploadDir }; diff --git a/src/modules/task/models/task.model.js b/src/modules/task/models/task.model.js index 9961d12..9d51588 100644 --- a/src/modules/task/models/task.model.js +++ b/src/modules/task/models/task.model.js @@ -67,8 +67,16 @@ const submitTaskModel = joi.object().keys({ }), }); +const updateSubmissionModel = joi.object().keys({ + url_link: joi.string().uri().optional().messages({ + 'string.base': 'URL link must be a string.', + 'string.uri': 'URL link must be a valid URL.', + }), +}); + module.exports = { createTaskModel, updateTaskModel, submitTaskModel, + updateSubmissionModel, }; diff --git a/src/modules/task/repositories/task.repository.js b/src/modules/task/repositories/task.repository.js index 651994a..28e5d24 100644 --- a/src/modules/task/repositories/task.repository.js +++ b/src/modules/task/repositories/task.repository.js @@ -140,6 +140,38 @@ class TaskRepository { }); } + async findSubmissionById(idSubmission) { + return prisma.taskSubmission.findUnique({ + where: { + id: parseInt(idSubmission), + }, + include: { + task: { + select: { + id: true, + id_project: true, + title: true, + submission_type: true, + deadline_at: true, + project: { + select: { + id: true, + project_name: true, + }, + }, + }, + }, + user: { + select: { + id: true, + full_name: true, + email: true, + }, + }, + }, + }); + } + async createSubmission(submissionData) { return prisma.taskSubmission.create({ data: submissionData, @@ -154,6 +186,14 @@ class TaskRepository { data: submissionData, }); } + + async deleteSubmission(idSubmission) { + return prisma.taskSubmission.delete({ + where: { + id: parseInt(idSubmission), + }, + }); + } } module.exports = new TaskRepository(); diff --git a/src/modules/task/services/task.service.js b/src/modules/task/services/task.service.js index de9f41a..a92e3ab 100644 --- a/src/modules/task/services/task.service.js +++ b/src/modules/task/services/task.service.js @@ -1,4 +1,7 @@ +const fs = require('fs'); +const path = require('path'); const taskRepository = require('../repositories/task.repository'); +const { taskUploadDir } = require('../helpers/taskUpload.helper'); const { data, error } = require('../../../helpers/utils/wrapper'); const { BadRequestError, @@ -261,6 +264,7 @@ class TaskService { let submission; if (existingSubmission) { + this.deleteLocalSubmissionFile(existingSubmission.file_path); submission = await taskRepository.updateSubmission(existingSubmission.id, submissionData); } else { submission = await taskRepository.createSubmission(submissionData); @@ -272,6 +276,115 @@ class TaskService { } } + async updateSubmission(idSubmission, payload, file, actor = {}) { + try { + if (!this.isPositiveInteger(idSubmission)) { + return error(new BadRequestError('Submission ID must be a valid number')); + } + + if (!actor?.id || actor.role !== 'intern') { + return error(new ForbiddenError('Access denied: only intern can update submission')); + } + + const submission = await taskRepository.findSubmissionById(idSubmission); + + if (!submission) { + return error(new NotFoundError('Submission not found')); + } + + if (submission.id_user !== parseInt(actor.id)) { + return error(new ForbiddenError('Access denied: you can only update your own submission')); + } + + const membership = await taskRepository.checkActiveProjectMember( + submission.task.id_project, + actor.id + ); + + if (!membership) { + return error(new ForbiddenError('Access denied: you are not a member of this project')); + } + + const updateData = {}; + + if (submission.task.submission_type === 'file_upload') { + if (!file) { + return error(new BadRequestError('Submission file is required for this task')); + } + + if (payload.url_link) { + return error(new BadRequestError('URL link is not allowed for file upload task')); + } + + updateData.file_path = `/uploads/task-submissions/${file.filename}`; + updateData.url_link = null; + } + + if (submission.task.submission_type === 'url_link') { + if (file) { + return error(new BadRequestError('File upload is not allowed for URL link task')); + } + + if (!payload.url_link) { + return error(new BadRequestError('URL link is required for this task')); + } + + updateData.file_path = null; + updateData.url_link = payload.url_link; + } + + this.deleteLocalSubmissionFile(submission.file_path); + + const updatedSubmission = await taskRepository.updateSubmission( + idSubmission, + updateData + ); + + return data(this.mapSubmission(updatedSubmission)); + } catch (err) { + return error(err); + } + } + + async deleteSubmission(idSubmission, actor = {}) { + try { + if (!this.isPositiveInteger(idSubmission)) { + return error(new BadRequestError('Submission ID must be a valid number')); + } + + if (!actor?.id || actor.role !== 'intern') { + return error(new ForbiddenError('Access denied: only intern can delete submission')); + } + + const submission = await taskRepository.findSubmissionById(idSubmission); + + if (!submission) { + return error(new NotFoundError('Submission not found')); + } + + if (submission.id_user !== parseInt(actor.id)) { + return error(new ForbiddenError('Access denied: you can only delete your own submission')); + } + + const membership = await taskRepository.checkActiveProjectMember( + submission.task.id_project, + actor.id + ); + + if (!membership) { + return error(new ForbiddenError('Access denied: you are not a member of this project')); + } + + await taskRepository.deleteSubmission(idSubmission); + + this.deleteLocalSubmissionFile(submission.file_path); + + return data(null); + } catch (err) { + return error(err); + } + } + async validateProjectAccess(idProject, actor = {}) { if (!actor?.id || !actor?.role) { return new ForbiddenError('Access denied: authentication data is missing'); @@ -432,6 +545,27 @@ class TaskService { }; } + deleteLocalSubmissionFile(filePath) { + if (!filePath || typeof filePath !== 'string') { + return; + } + + if (!filePath.startsWith('/uploads/task-submissions/')) { + return; + } + + if (!taskUploadDir || typeof taskUploadDir !== 'string') { + return; + } + + const filename = path.basename(filePath); + const absolutePath = path.join(taskUploadDir, filename); + + if (fs.existsSync(absolutePath)) { + fs.unlinkSync(absolutePath); + } + } + isPositiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0; diff --git a/src/routes/task.routes.js b/src/routes/task.routes.js index 3f8abc7..ed84943 100644 --- a/src/routes/task.routes.js +++ b/src/routes/task.routes.js @@ -594,4 +594,124 @@ router.post( taskController.submitTask ); +/** + * @swagger + * /task-api/submissions/{id}: + * patch: + * summary: Update submission + * description: Update an intern submission. Only the owner intern can update their own submission. For file_upload tasks, send submission_file. For url_link tasks, send url_link. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Submission ID + * example: 1 + * requestBody: + * required: false + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * submission_file: + * type: string + * format: binary + * description: Required when related task submission_type is file_upload. Allowed file types are PDF, DOCX, and ZIP. Max size is 10MB. + * url_link: + * type: string + * format: uri + * description: Required when related task submission_type is url_link. + * application/json: + * schema: + * type: object + * properties: + * url_link: + * type: string + * format: uri + * example: https://drive.google.com/file/example-updated + * responses: + * 200: + * description: Submission updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TaskSubmissionResponse' + * 400: + * description: Bad request + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Submission not found + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.patch( + '/submissions/:id', + verifyJWT, + taskUpload.single('submission_file'), + taskUploadErrorHandler, + taskController.updateSubmission +); + +/** + * @swagger + * /task-api/submissions/{id}: + * delete: + * summary: Delete submission + * description: Delete an intern submission. Only the owner intern can delete their own submission. + * tags: [Task] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Submission ID + * example: 1 + * responses: + * 200: + * description: Submission deleted successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * nullable: true + * example: null + * message: + * type: string + * example: Submission deleted successfully + * code: + * type: integer + * example: 200 + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Submission not found + * 500: + * description: Internal server error + */ +router.delete( + '/submissions/:id', + verifyJWT, + taskController.deleteSubmission +); + module.exports = router;