diff --git a/src/modules/project/controllers/project.controller.js b/src/modules/project/controllers/project.controller.js index 40f3a81..17f63fa 100644 --- a/src/modules/project/controllers/project.controller.js +++ b/src/modules/project/controllers/project.controller.js @@ -267,6 +267,27 @@ class ProjectController { ); } } + + async removeMember(req, res) { + try { + const actor = getActor(req); + const result = await projectService.removeMember(req.body, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Member removed successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } } module.exports = new ProjectController(); diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index 699ce83..39f3aea 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -571,6 +571,54 @@ class ProjectService { } } + async removeMember(payload, 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 mentors/admins can remove members"), + ); + } + + const { id_project, id_user } = payload; + + if (!id_project || !id_user) { + return error(new BadRequestError("Project ID and User ID are required")); + } + + const project = await projectRepository.findById(id_project); + if (!project) { + return error(new NotFoundError("Project not found")); + } + + const user = await projectRepository.findUserByIdAndActive(id_user); + if (!user) { + return error(new NotFoundError("User not found or inactive")); + } + + const existingMembership = await projectRepository.findMembership(id_project, id_user); + + if (!existingMembership || existingMembership.status !== 'active') { + return error(new BadRequestError("User is not an active member of this project")); + } + + const membership = await projectRepository.updateMembershipStatus(existingMembership.id, 'removed'); + + await notificationService.createNotificationForIntern( + id_user, + "Dikeluarkan dari Proyek", + `Anda telah dikeluarkan dari proyek '${project.project_name}'.` + ).catch(err => console.error("Failed to send project removal notification:", err)); + + return data(membership); + } catch (err) { + return error(err); + } + } + async generateUniqueProjectSlug(projectName, excludeProjectId = null) { const baseSlug = slugify(projectName, 'project'); diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index e5ac423..4146f0a 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -852,4 +852,59 @@ router.get('/interns', verifyJWT, isMentorOrAdmin, projectController.getInterns) */ router.post('/assign-member', verifyJWT, isMentorOrAdmin, projectController.assignMember); +/** + * @swagger + * /project-api/remove-member: + * post: + * summary: Remove an intern from a project + * description: Removes an intern from a project by project ID and user ID. Only accessible by mentor/admin. + * tags: [Project] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - id_project + * - id_user + * properties: + * id_project: + * type: integer + * example: 1 + * id_user: + * type: integer + * example: 2 + * responses: + * 200: + * description: Member removed successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * message: + * type: string + * example: Member removed successfully + * code: + * type: integer + * example: 200 + * 400: + * description: Bad request + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Only for admin/mentors) + * 404: + * description: Project or User not found + * 500: + * description: Internal server error + */ +router.post('/remove-member', verifyJWT, isMentorOrAdmin, projectController.removeMember); + module.exports = router; diff --git a/test/test_mentor_admin.js b/test/test_mentor_admin.js index 43b1123..9e41d2b 100644 --- a/test/test_mentor_admin.js +++ b/test/test_mentor_admin.js @@ -101,11 +101,83 @@ async function runTests() { } } - // 8. Admin accesses Project details of the mentor\'s project + // 8. Admin accesses Project details of the mentor's project console.log(`8. Admin accessing details of the Mentor's project (ID: ${createdProjectId})...`); const adminDetailRes = await axios.get(`${BASE_URL}/project-api/get/${createdProjectId}`, getHeader(adminToken)); console.log(` PASSED: Admin retrieved project details successfully (Name: ${adminDetailRes.data.data.project_name}).\n`); + // 9. Assign and Remove Member Test + console.log('9. Testing Assign and Remove Member feature...'); + // 9.1. Get active interns + console.log(' Fetching active interns...'); + const internsRes = await axios.get(`${BASE_URL}/project-api/interns`, getHeader(mentorToken)); + const interns = internsRes.data.data; + if (interns.length === 0) { + throw new Error('TEST FAILED: No interns found to test assignment!'); + } + const testIntern = interns[0]; + console.log(` Found intern: ${testIntern.name} (ID: ${testIntern.id})`); + + // 9.2. Assign intern to mentor's project + console.log(` Assigning intern ID: ${testIntern.id} to project ID: ${createdProjectId}...`); + await axios.post(`${BASE_URL}/project-api/assign-member`, { + id_project: createdProjectId, + id_user: testIntern.id + }, getHeader(mentorToken)); + console.log(' PASSED: Intern assigned successfully.'); + + // 9.3. Try to remove the intern as an intern (should fail with 401/403) + console.log(' Testing authorization: Intern trying to remove themselves or others...'); + let internToken = ''; + try { + const internLoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: 'rafi@student.com', + password: 'password123' + }); + internToken = internLoginRes.data.data.token; + + await axios.post(`${BASE_URL}/project-api/remove-member`, { + id_project: createdProjectId, + id_user: testIntern.id + }, getHeader(internToken)); + throw new Error('TEST FAILED: Intern successfully removed a member!'); + } catch (err) { + if (err.response && (err.response.status === 401 || err.response.status === 403)) { + console.log(' PASSED: Intern request denied with 401 Unauthorized / 403 Forbidden as expected.'); + } else { + throw err; + } + } + + // 9.4. Remove the intern as Mentor (should succeed with 200) + console.log(` Removing intern ID: ${testIntern.id} from project ID: ${createdProjectId} as Mentor...`); + const removeRes = await axios.post(`${BASE_URL}/project-api/remove-member`, { + id_project: createdProjectId, + id_user: testIntern.id + }, getHeader(mentorToken)); + + if (removeRes.status === 200 && removeRes.data.status === true) { + console.log(' PASSED: Intern removed successfully (200 OK).'); + } else { + throw new Error(`TEST FAILED: Failed to remove intern. Response: ${JSON.stringify(removeRes.data)}`); + } + + // 9.5. Try to remove the intern again (should fail with 400 because already removed) + console.log(' Trying to remove the already removed intern again...'); + try { + await axios.post(`${BASE_URL}/project-api/remove-member`, { + id_project: createdProjectId, + id_user: testIntern.id + }, getHeader(mentorToken)); + throw new Error('TEST FAILED: Successfully removed already removed intern!'); + } catch (err) { + if (err.response && err.response.status === 400) { + console.log(' PASSED: Request denied with 400 Bad Request as expected.\n'); + } else { + throw err; + } + } + console.log('=== ALL INTEGRATION TESTS PASSED SUCCESSFULLY! ==='); } catch (err) { console.error('=== TEST EXECUTION ENCOUNTERED AN ERROR ===');