From d32293b998ec5e1f563c343df97fb07e2902d5bc Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Wed, 1 Jul 2026 21:16:16 +0700 Subject: [PATCH 1/2] feat(project): endpoint untuk complete sebuah project, update project biasa kini tanpa status --- .../project/controllers/project.controller.js | 23 +++++++++ src/modules/project/models/project.model.js | 5 -- .../repositories/project.repository.js | 12 +++++ .../project/services/project.service.js | 51 +++++++++++++++++++ src/routes/project.routes.js | 43 ++++++++++++++-- 5 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/modules/project/controllers/project.controller.js b/src/modules/project/controllers/project.controller.js index 19a677a..b4a3d61 100644 --- a/src/modules/project/controllers/project.controller.js +++ b/src/modules/project/controllers/project.controller.js @@ -163,6 +163,29 @@ class ProjectController { } } + async completeProject(req, res) { + try { + const { id } = req.params; + const actor = getActor(req); + + const result = await projectService.completeProject(id, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Project completed successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + async getMyProjects(req, res) { try { const actor = getActor(req); diff --git a/src/modules/project/models/project.model.js b/src/modules/project/models/project.model.js index b4f7410..7e5d779 100644 --- a/src/modules/project/models/project.model.js +++ b/src/modules/project/models/project.model.js @@ -80,11 +80,6 @@ const updateProjectModel = joi.object().keys({ end_date: joi.date().optional().messages({ 'date.base': 'End date must be a valid date.', }), - - status: joi.string().valid(...PROJECT_STATUS_VALUES).optional().messages({ - 'string.base': 'Project status must be a string.', - 'any.only': `Project status must be one of: ${PROJECT_STATUS_VALUES.join(', ')}.`, - }), }); const getAllProjectsModel = joi.object().keys({ diff --git a/src/modules/project/repositories/project.repository.js b/src/modules/project/repositories/project.repository.js index 5644875..71351e8 100644 --- a/src/modules/project/repositories/project.repository.js +++ b/src/modules/project/repositories/project.repository.js @@ -151,6 +151,18 @@ class ProjectRepository { }); } + async completeById(id) { + return prisma.project.update({ + where: { + id: parseInt(id), + }, + data: { + status: "completed", + }, + include: this.projectDetailInclude(), + }); + } + async checkActiveMember(idProject, idUser) { return prisma.projectMember.findFirst({ where: { diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index 52de8b6..dec3945 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -259,6 +259,57 @@ class ProjectService { } } + async completeProject(id, actor = {}) { + try { + if (!actor?.id) { + return error(new BadRequestError("Admin ID is required")); + } + + if (actor.role !== "admin" && actor.role !== "mentor") { + return error( + new ForbiddenError("Access denied: only admin or mentor can complete project"), + ); + } + + const existingProject = await projectRepository.findByIdOrSlug(id); + + if (!existingProject) { + return error(new NotFoundError("Project not found")); + } + + const accessError = await this.validateProjectAccess(existingProject.id, actor); + if (accessError) { + return error(accessError); + } + + if (existingProject.status === "completed") { + return data(this.mapProjectDetail(existingProject)); + } + + if (existingProject.status === "archived") { + return error(new ConflictError("Archived project cannot be marked as completed")); + } + + if (existingProject.status !== "active") { + return error(new ConflictError("Only active project can be marked as completed")); + } + + const completedProject = await projectRepository.completeById(existingProject.id); + + await notificationService.createNotificationForProjectInterns( + completedProject.id, + "Proyek Selesai", + `Proyek '${completedProject.project_name}' telah ditandai selesai.` + ).catch((err) => + console.error("Failed to send project completion notifications:", err) + ); + + return data(this.mapProjectDetail(completedProject)); + } catch (err) { + return error(err); + } + } + async validateProjectAccess(idProject, actor = {}) { if (!actor?.id || !actor?.role) { return new ForbiddenError( diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 7762a8e..baee279 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -101,10 +101,6 @@ const router = express.Router(); * type: string * format: date * example: 2026-08-20 - * status: - * type: string - * enum: [active, completed, archived] - * example: completed * ProjectAdminSummary: * type: object * properties: @@ -730,6 +726,45 @@ router.patch('/update/:id', verifyJWT, isMentorOrAdmin, projectController.update */ router.delete('/delete/:id', verifyJWT, isMentorOrAdmin, projectController.archiveProject); +/** + * @swagger + * /project-api/complete/{id}: + * patch: + * summary: Mark project as completed + * description: Mark an active project as completed. Completed projects are locked, so tasks and submissions can no longer be modified. The path parameter supports project ID or slug. + * tags: [Project] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * schema: + * oneOf: + * - type: integer + * - type: string + * required: true + * description: Project ID or slug + * example: internify-platform-dev + * responses: + * 200: + * description: Project completed successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProjectDetailResponse' + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Project not found + * 409: + * description: Project cannot be completed + * 500: + * description: Internal server error + */ +router.patch('/complete/:id', verifyJWT, isMentorOrAdmin, projectController.completeProject); + /** * @swagger * /project-api/my-projects: From f28c22dc0067540d0fad44c8245bb6c9933dc30c Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Wed, 1 Jul 2026 21:31:27 +0700 Subject: [PATCH 2/2] feat(project): update status project ke completed dan apply restrictionnya --- .../project/services/project.service.js | 3 + .../task/repositories/task.repository.js | 3 + src/modules/task/services/task.service.js | 61 +++++++++++++++---- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index dec3945..0dda444 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -740,6 +740,9 @@ class ProjectService { if (!project) { return error(new NotFoundError("Project not found")); } + if (project.status !== "active") { + return error(new ConflictError("Cannot remove member from a non-active project")); + } const user = await projectRepository.findUserByIdAndActive(id_user); if (!user) { diff --git a/src/modules/task/repositories/task.repository.js b/src/modules/task/repositories/task.repository.js index db22cfb..f272093 100644 --- a/src/modules/task/repositories/task.repository.js +++ b/src/modules/task/repositories/task.repository.js @@ -63,6 +63,7 @@ class TaskRepository { slug: true, project_name: true, project_icon: true, + status: true, members: { where: { status: 'active', @@ -159,6 +160,7 @@ class TaskRepository { select: { id: true, project_name: true, + status: true, }, }, }, @@ -236,6 +238,7 @@ class TaskRepository { slug: true, project_name: true, project_icon: true, + status: true, members: { where: { status: 'active', diff --git a/src/modules/task/services/task.service.js b/src/modules/task/services/task.service.js index 1f897f3..bc45186 100644 --- a/src/modules/task/services/task.service.js +++ b/src/modules/task/services/task.service.js @@ -35,6 +35,11 @@ class TaskService { return error(accessError); } + const lockError = this.validateProjectIsActive(project, 'create task'); + if (lockError) { + return error(lockError); + } + const taskSlug = await this.generateUniqueTaskSlug(project.id, payload.title); const deadlineAt = this.buildDeadlineAt(payload.deadline_date, payload.specific_time); @@ -159,11 +164,15 @@ class TaskService { } const accessError = await this.validateProjectAccess(existingTask.id_project, actor); - if (accessError) { return error(accessError); } + const lockError = this.validateProjectIsActive(existingTask.project, 'update task'); + if (lockError) { + return error(lockError); + } + const submissionCount = await taskRepository.countSubmissionsByTask(existingTask.id); if ( @@ -225,11 +234,15 @@ class TaskService { } const accessError = await this.validateProjectAccess(task.id_project, actor); - if (accessError) { return error(accessError); } + const lockError = this.validateProjectIsActive(task.project, 'delete task'); + if (lockError) { + return error(lockError); + } + const submissionCount = await taskRepository.countSubmissionsByTask(task.id); if (submissionCount > 0) { @@ -255,11 +268,15 @@ class TaskService { } const task = await this.resolveTask(idTask, projectIdentifier); - if (!task) { return error(new NotFoundError('Task not found')); } + const lockError = this.validateProjectIsActive(task.project, 'submit task'); + if (lockError) { + return error(lockError); + } + const membership = await taskRepository.checkActiveProjectMember(task.id_project, actor.id); if (!membership) { @@ -334,17 +351,14 @@ class TaskService { 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')); } @@ -353,11 +367,18 @@ class TaskService { submission.task.id_project, actor.id ); - if (!membership) { return error(new ForbiddenError('Access denied: you are not a member of this project')); } + const lockError = this.validateProjectIsActive( + submission.task.project, + 'update submission' + ); + if (lockError) { + return error(lockError); + } + const updateData = {}; if (submission.task.submission_type === 'file_upload') { @@ -404,17 +425,14 @@ class TaskService { 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')); } @@ -423,11 +441,18 @@ class TaskService { submission.task.id_project, actor.id ); - if (!membership) { return error(new ForbiddenError('Access denied: you are not a member of this project')); } + const lockError = this.validateProjectIsActive( + submission.task.project, + 'delete submission' + ); + if (lockError) { + return error(lockError); + } + await taskRepository.deleteSubmission(idSubmission); this.deleteLocalSubmissionFile(submission.file_path); @@ -470,6 +495,20 @@ class TaskService { return new ForbiddenError('Access denied: invalid role'); } + validateProjectIsActive(project, action = 'modify this project') { + if (!project) { + return new NotFoundError('Project not found'); + } + + if (project.status !== 'active') { + return new ConflictError( + `Cannot ${action} because this project is already ${project.status}` + ); + } + + return null; + } + isMentorOrAdmin(actor) { return actor?.role === 'admin' || actor?.role === 'mentor'; }