diff --git a/src/modules/certificate/controllers/certificate.controller.js b/src/modules/certificate/controllers/certificate.controller.js index a758c2d..2ed2b3a 100644 --- a/src/modules/certificate/controllers/certificate.controller.js +++ b/src/modules/certificate/controllers/certificate.controller.js @@ -3,7 +3,7 @@ const { response, error } = require('../../../helpers/utils/wrapper'); const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code'); const { InternalServerError, BadRequestError } = require('../../../helpers/error'); const { isValidPayload } = require('../../../helpers/utils/validator'); -const { claimCertificateModel } = require('../models/certificate.model'); +const { claimCertificateModel, generateCertificatesModel } = require('../models/certificate.model'); const getActor = (req) => ({ id: req.id, @@ -45,6 +45,39 @@ class CertificateController { } } + async generateCertificates(req, res) { + try { + const validatePayload = isValidPayload(req.body, generateCertificatesModel); + + if (validatePayload.err) { + return response( + res, + 'fail', + { err: validatePayload.err, data: null }, + 'Data generate certificate tidak valid', + ERROR.EXPECTATION_FAILED + ); + } + + const actor = getActor(req); + const result = await certificateService.generateCertificates(validatePayload.data, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Certificates generated 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); diff --git a/src/modules/certificate/models/certificate.model.js b/src/modules/certificate/models/certificate.model.js index 81d67b7..9989187 100644 --- a/src/modules/certificate/models/certificate.model.js +++ b/src/modules/certificate/models/certificate.model.js @@ -8,6 +8,20 @@ const claimCertificateModel = joi.object().keys({ }), }); +const generateCertificatesModel = 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.', + }), + id_users: joi.array().items(joi.number().integer()).min(1).required().messages({ + 'array.base': 'Intern IDs harus berupa array.', + 'array.min': 'Pilih minimal satu intern.', + 'any.required': 'Intern IDs wajib diisi.', + }), +}); + module.exports = { claimCertificateModel, + generateCertificatesModel, }; diff --git a/src/modules/certificate/services/certificate.service.js b/src/modules/certificate/services/certificate.service.js index cb46a06..860789b 100644 --- a/src/modules/certificate/services/certificate.service.js +++ b/src/modules/certificate/services/certificate.service.js @@ -80,6 +80,102 @@ class CertificateService { } } + async generateCertificates(payload, actor) { + try { + if (!actor?.id) { + return error(new BadRequestError('User ID is required')); + } + + if (actor.role !== 'admin' && actor.role !== 'mentor') { + return error( + new ForbiddenError('Access denied: only admins and mentors can generate certificates'), + ); + } + + const { id_project, id_users } = payload; + + const project = await certificateRepository.findProjectById(id_project); + if (!project) { + return error(new NotFoundError('Project not found')); + } + + // Authorization check: + if (actor.role === 'mentor' && project.id_admin !== parseInt(actor.id)) { + return error( + new ForbiddenError('Access denied: you are not the mentor of this project'), + ); + } + + const validatedUsers = []; + for (const userId of id_users) { + const existingCert = await certificateRepository.findByUserAndProject(userId, id_project); + if (existingCert) { + return error( + new ConflictError(`Certificate already generated/claimed for user ID ${userId}`), + ); + } + + const projectInfo = await certificateRepository.getProjectTasksAndSubmissions(id_project, userId); + if (!projectInfo) { + return error(new NotFoundError('Project not found')); + } + + if (!projectInfo.members || projectInfo.members.length === 0) { + return error( + new ForbiddenError(`User ID ${userId} is not an active member of this project`), + ); + } + + if (!projectInfo.tasks || projectInfo.tasks.length === 0) { + return error( + new BadRequestError(`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( + `User ID ${userId} is not eligible: completed ${completedTasks.length} out of ${projectInfo.tasks.length} tasks`, + ), + ); + } + + validatedUsers.push(userId); + } + + const generatedCertificates = []; + 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}`; + + for (const userId of validatedUsers) { + const certificateNo = `CERT/${dateString}/PRJ${id_project}/USR${userId}`; + const certificateData = { + id_project: parseInt(id_project), + id_user: parseInt(userId), + certificate_no: certificateNo, + issued_at: now, + }; + + const certificate = await certificateRepository.create(certificateData); + generatedCertificates.push(this.mapCertificate(certificate)); + } + + return data({ + message: `${generatedCertificates.length} certificates generated successfully.`, + certificates: generatedCertificates, + }); + } catch (err) { + return error(err); + } + } + async getMyCertificates(actor) { try { if (!actor?.id) { diff --git a/src/modules/project/repositories/project.repository.js b/src/modules/project/repositories/project.repository.js index 43ebc2a..e5470ac 100644 --- a/src/modules/project/repositories/project.repository.js +++ b/src/modules/project/repositories/project.repository.js @@ -210,11 +210,25 @@ class ProjectRepository { submission_type: true, created_at: true, updated_at: true, + submissions: { + select: { + id: true, + id_user: true, + }, + }, }, orderBy: { deadline_at: "asc", }, }, + certificates: { + select: { + id_user: true, + certificate_no: true, + uuid: true, + issued_at: true, + }, + }, }; } diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index 1e21a5f..1d26c9d 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -358,6 +358,7 @@ class ProjectService { members: project.members?.map((member) => this.mapProjectMember(member)) || [], tasks: project.tasks || [], + certificates: project.certificates || [], total_members: project.members?.length || 0, total_tasks: project.tasks?.length || 0, created_at: project.created_at, diff --git a/src/routes/certificate.routes.js b/src/routes/certificate.routes.js index 16ff5ae..066cba2 100644 --- a/src/routes/certificate.routes.js +++ b/src/routes/certificate.routes.js @@ -114,6 +114,50 @@ const router = express.Router(); */ router.post('/claim', verifyJWT, certificateController.claimCertificate); +/** + * @swagger + * /certificate-api/generate: + * post: + * summary: Batch generate certificates (Admin/Mentor only) + * description: Mentors or Admins can batch-generate certificates for selected eligible interns of a project. + * tags: [Certificate] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - id_project + * - id_users + * properties: + * id_project: + * type: integer + * example: 2 + * id_users: + * type: array + * items: + * type: integer + * example: [5, 6] + * responses: + * 201: + * description: Certificates generated successfully + * 400: + * description: Bad request (not eligible or missing parameters) + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (only mentors/admins of the project can generate) + * 409: + * description: Conflict (certificate already generated for one or more interns) + * 500: + * description: Internal server error + */ +router.post('/generate', verifyJWT, isMentorOrAdmin, certificateController.generateCertificates); + + /** * @swagger * /certificate-api/my-certificates: diff --git a/test/test_batch_generate_certificate.js b/test/test_batch_generate_certificate.js new file mode 100644 index 0000000..b54e8cb --- /dev/null +++ b/test/test_batch_generate_certificate.js @@ -0,0 +1,337 @@ +const axios = require('axios'); +const bcrypt = require('bcrypt'); +const { PrismaClient } = require('../src/generated/prisma'); + +const BASE_URL = 'http://localhost:9000'; +const prisma = new PrismaClient(); + +function createMultipartPayload(boundary, fields, files) { + const chunks = []; + + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined && value !== null) { + chunks.push(Buffer.from(`--${boundary}\r\n` + + `Content-Disposition: form-data; name="${key}"\r\n\r\n` + + `${value}\r\n`)); + } + } + + for (const [key, file] of Object.entries(files)) { + if (file) { + chunks.push(Buffer.from(`--${boundary}\r\n` + + `Content-Disposition: form-data; name="${key}"; filename="${file.name}"\r\n` + + `Content-Type: ${file.type}\r\n\r\n`)); + chunks.push(file.content); + chunks.push(Buffer.from('\r\n')); + } + } + + chunks.push(Buffer.from(`--${boundary}--\r\n`)); + return Buffer.concat(chunks); +} + +async function registerIntern(adminToken, email, firstName, lastName) { + // Create lowongan magang + const lwnBoundary = '----WebKitFormBoundaryLowonganUpload'; + const lwnFields = { + posisi: `Web Developer Test - ${Date.now()}`, + kelompok_peminatan: 'Software Engineering', + jobdesk: 'Testing batch certificate generation.', + lokasi: 'Remote', + kualifikasi: 'NodeJS', + benefit: 'Certificate', + durasi_awal: '2026-07-01', + durasi_akhir: '2026-10-01', + paid: 'unpaid' + }; + const lwnFiles = { + image: { name: 'poster.png', type: 'image/png', content: Buffer.from('fake-image-data') } + }; + const lwnPayload = createMultipartPayload(lwnBoundary, lwnFields, lwnFiles); + + const createLwnRes = await axios.post( + `${BASE_URL}/lowongan-magang-api/add`, + lwnPayload, + { + headers: { + Authorization: `Bearer ${adminToken}`, + 'Content-Type': `multipart/form-data; boundary=${lwnBoundary}` + } + } + ); + const lowonganId = createLwnRes.data.data.id; + + // Submit application + const appBoundary = '----WebKitFormBoundaryApplicationSubmission'; + const appFields = { + nama_depan: firstName, + nama_belakang: lastName, + email: email, + kontak: '08123456789', + jurusan: 'Computer Science', + universitas: 'Intern University', + negara: 'Indonesia', + motivasi: 'I want to learn.', + relevant_skills: 'NodeJS, React' + }; + const appFiles = { + cv: { name: 'cv.pdf', type: 'application/pdf', content: Buffer.from('fake-pdf-cv') }, + portofolio: { name: 'portfolio.pdf', type: 'application/pdf', content: Buffer.from('fake-pdf-portfolio') } + }; + const appPayload = createMultipartPayload(appBoundary, appFields, appFiles); + + const applyRes = await axios.post( + `${BASE_URL}/lamaran-magang-api/add-mobile/${lowonganId}`, + appPayload, + { + headers: { + 'Content-Type': `multipart/form-data; boundary=${appBoundary}` + } + } + ); + const idMahasiswa = applyRes.data.data.id_mahasiswa; + + // Wait a brief moment + await new Promise(resolve => setTimeout(resolve, 500)); + + // Find lamaran ID + const listLamaranRes = await axios.get(`${BASE_URL}/lamaran-magang-api/get?limit=100`, { + headers: { Authorization: `Bearer ${adminToken}` } + }); + const lamaranItem = listLamaranRes.data.data.find(l => l.id_mahasiswa === idMahasiswa); + if (!lamaranItem) { + throw new Error(`Failed to find lamaran for student ID: ${idMahasiswa}`); + } + const lamaranId = lamaranItem.id; + + // Accept application + await axios.patch(`${BASE_URL}/lamaran-magang-api/update/${lamaranId}`, { + status: 'diterima' + }, { + headers: { Authorization: `Bearer ${adminToken}` } + }); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Force override password + const hashedPassword = await bcrypt.hash('password123', 10); + await prisma.user.update({ + where: { email }, + data: { password: hashedPassword } + }); + + // Login + const loginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email, + password: 'password123' + }); + + const user = await prisma.user.findUnique({ + where: { email } + }); + + return { + userId: user.id, + token: loginRes.data.data.token + }; +} + +async function runTests() { + console.log('=== STARTING BATCH GENERATE CERTIFICATE TESTS ===\n'); + + let adminToken = ''; + let mentor1Token = ''; + let mentor2Token = ''; + let project1Id = null; + + const password = 'password123'; + const intern1Email = `intern1_batch_${Date.now()}@internify.com`; + const intern2Email = `intern2_batch_${Date.now()}@internify.com`; + + const getHeader = (token) => ({ + headers: { Authorization: `Bearer ${token}` } + }); + + try { + // 1. Login Admin + console.log('1. Logging in as Admin (admin1@internify.com)...'); + const adminLoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: 'admin1@internify.com', + password: password + }); + adminToken = adminLoginRes.data.data.token; + + // 2. Login Mentors + console.log('2. Logging in as Mentors...'); + const mentor1Login = await axios.post(`${BASE_URL}/auth-api/login`, { + email: 'mentor1@internify.com', + password + }); + mentor1Token = mentor1Login.data.data.token; + + const mentor2Login = await axios.post(`${BASE_URL}/auth-api/login`, { + email: 'mentor2@internify.com', + password + }); + mentor2Token = mentor2Login.data.data.token; + + // 3. Register Intern 1 and Intern 2 + console.log('3. Registering Intern 1 & Intern 2...'); + const intern1 = await registerIntern(adminToken, intern1Email, 'InternOne', 'Batch'); + console.log(` Registered Intern 1: User ID ${intern1.userId}`); + + const intern2 = await registerIntern(adminToken, intern2Email, 'InternTwo', 'Batch'); + console.log(` Registered Intern 2: User ID ${intern2.userId}`); + + // 4. Create Project owned by Mentor 1 and assign both interns + console.log('4. Creating Project under Mentor 1 with both interns...'); + const createProjectRes = await axios.post(`${BASE_URL}/project-api/add`, { + project_icon: 'code', + project_name: `Batch Certificate Project - ${Date.now()}`, + description: 'Project to test batch generation.', + start_date: '2026-07-01', + end_date: '2026-10-01', + member_emails: [intern1Email, intern2Email] + }, getHeader(mentor1Token)); + project1Id = createProjectRes.data.data.id; + console.log(` Project created successfully! ID: ${project1Id}`); + + // 5. Create a task in the project + console.log('5. Creating a required project task...'); + const task = await prisma.task.create({ + data: { + id_project: project1Id, + slug: `required-task-${Date.now()}`, + title: 'Project Submission', + description: 'Complete project tasks.', + deadline_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), + submission_type: 'url_link' + } + }); + console.log(` Task created! ID: ${task.id}`); + + // 6. Submit task for Intern 1 only + console.log('6. Submitting task for Intern 1 only...'); + await prisma.taskSubmission.create({ + data: { + id_task: task.id, + id_user: intern1.userId, + url_link: 'https://github.com/intern1/solution' + } + }); + console.log(' Intern 1 task submitted successfully. Intern 2 has NO submissions.'); + + // 7. Test Authorization: Call generate endpoint using Intern 1 token (Should fail - 401) + console.log('\n--- API TEST 1: Intern calls generate endpoint (Should fail - 401) ---'); + try { + await axios.post(`${BASE_URL}/certificate-api/generate`, { + id_project: project1Id, + id_users: [intern1.userId] + }, getHeader(intern1.token)); + throw new Error('TEST FAILED: Intern generated certificate!'); + } catch (err) { + if (err.response && err.response.status === 401) { + console.log(' PASSED: Denied access to intern with 401 Unauthorized.'); + } else { + throw err; + } + } + + // 8. Test Authorization: Call generate endpoint using Mentor 2 token (Should fail - 403) + console.log('\n--- API TEST 2: Mentor 2 (non-owner) calls generate endpoint (Should fail - 403) ---'); + try { + await axios.post(`${BASE_URL}/certificate-api/generate`, { + id_project: project1Id, + id_users: [intern1.userId] + }, getHeader(mentor2Token)); + throw new Error('TEST FAILED: Unauthorized mentor generated certificate!'); + } catch (err) { + if (err.response && err.response.status === 403) { + console.log(' PASSED: Denied access to unauthorized mentor with 403 Forbidden.'); + } else { + throw err; + } + } + + // 9. Test Eligibility: Request generation for both Intern 1 (completed) and Intern 2 (incomplete) (Should fail - 400) + console.log('\n--- API TEST 3: Generate batch containing ineligible intern (Should fail - 400) ---'); + try { + await axios.post(`${BASE_URL}/certificate-api/generate`, { + id_project: project1Id, + id_users: [intern1.userId, intern2.userId] + }, getHeader(mentor1Token)); + throw new Error('TEST FAILED: Generated certificate for batch containing ineligible intern!'); + } catch (err) { + if (err.response && err.response.status === 400) { + console.log(' PASSED: Rejected batch due to ineligible intern with 400 Bad Request.'); + console.log(' Error Message:', err.response.data.message); + } else { + throw err; + } + } + + // 10. Success case: Generate certificate for Intern 1 only (Should succeed - 201) + console.log('\n--- API TEST 4: Generate certificate for eligible Intern 1 (Should succeed - 201) ---'); + const genRes = await axios.post(`${BASE_URL}/certificate-api/generate`, { + id_project: project1Id, + id_users: [intern1.userId] + }, getHeader(mentor1Token)); + console.log(' Response status:', genRes.status); + if (genRes.status !== 201) { + throw new Error(`TEST FAILED: Expected 201 Created, got ${genRes.status}`); + } + console.log(' Generated Certificate No:', genRes.data.data.certificates[0].certificate_no); + console.log(' PASSED: Certificate generated successfully.'); + + // 11. Conflict check: Try to generate for Intern 1 again (Should fail - 409) + console.log('\n--- API TEST 5: Re-generate certificate for Intern 1 (Should fail - 409) ---'); + try { + await axios.post(`${BASE_URL}/certificate-api/generate`, { + id_project: project1Id, + id_users: [intern1.userId] + }, getHeader(mentor1Token)); + throw new Error('TEST FAILED: Re-generated certificate for Intern 1!'); + } catch (err) { + if (err.response && err.response.status === 409) { + console.log(' PASSED: Blocked duplicate generation with 409 Conflict.'); + } else { + throw err; + } + } + + // 12. Verify Project Details mapping: Check task submissions and certificates in details response + console.log('\n--- API TEST 6: Fetch project details and verify database mapping ---'); + const projectDetailsRes = await axios.get(`${BASE_URL}/project-api/get/${project1Id}`, getHeader(mentor1Token)); + const projectData = projectDetailsRes.data.data; + + // Verify certificates list exists + if (!projectData.certificates || projectData.certificates.length !== 1) { + throw new Error('TEST FAILED: Certificates list in project details is missing or incorrect!'); + } + console.log(' Found Certificates in Project details:', projectData.certificates); + + // Verify task submissions exist + const projectTask = projectData.tasks.find(t => t.id === task.id); + if (!projectTask || !projectTask.submissions || projectTask.submissions.length !== 1) { + throw new Error('TEST FAILED: Task submissions list in project details is missing or incorrect!'); + } + console.log(' Found Submissions in Project Task:', projectTask.submissions); + console.log(' PASSED: Database mapping checked successfully.'); + + console.log('\n=== ALL BATCH GENERATE CERTIFICATE TESTS PASSED SUCCESSFULLY! ==='); + await prisma.$disconnect(); + process.exit(0); + } catch (err) { + console.error('\n=== TEST EXECUTION ENCOUNTERED AN ERROR ==='); + if (err.response) { + console.error(`Status: ${err.response.status}`); + console.error('Response Data:', JSON.stringify(err.response.data, null, 2)); + } else { + console.error(err); + } + await prisma.$disconnect(); + process.exit(1); + } +} + +runTests();