diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e226758..3170178 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -207,7 +207,6 @@ model Project { description String @db.Text start_date DateTime @db.Date end_date DateTime @db.Date - max_members Int @default(8) status ProjectStatus @default(active) created_at DateTime @default(now()) updated_at DateTime @updatedAt @@ -277,6 +276,7 @@ model TaskSubmission { enum Role { admin + mentor intern } diff --git a/prisma/seed.js b/prisma/seed.js index c05cbaf..93a6b2b 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -151,13 +151,13 @@ async function main() { // 10. Project const proj1 = await prisma.project.create({ - data: { id_admin: admin1.id, project_icon: 'code', project_name: 'Internify Platform Dev', description: 'Upgrading the core Internify platform backend and web client.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), max_members: 5, status: 'active' } + data: { id_admin: admin1.id, project_icon: 'code', project_name: 'Internify Platform Dev', description: 'Upgrading the core Internify platform backend and web client.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), status: 'active' } }); const proj2 = await prisma.project.create({ - data: { id_admin: admin2.id, project_icon: 'shield', project_name: 'Auth Gateway Module', description: 'Securing API gateway with unified OAuth2 authentication.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), max_members: 3, status: 'active' } + data: { id_admin: admin2.id, project_icon: 'shield', project_name: 'Auth Gateway Module', description: 'Securing API gateway with unified OAuth2 authentication.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), status: 'active' } }); const proj3 = await prisma.project.create({ - data: { id_admin: admin1.id, project_icon: 'chart', project_name: 'Recruitment Analytics Dashboard', description: 'Developing analytical charts for recruitment metrics.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), max_members: 4, status: 'active' } + data: { id_admin: admin1.id, project_icon: 'chart', project_name: 'Recruitment Analytics Dashboard', description: 'Developing analytical charts for recruitment metrics.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), status: 'active' } }); // 11. LamaranMagang diff --git a/src/middleware/isMentorOrAdmin.js b/src/middleware/isMentorOrAdmin.js new file mode 100644 index 0000000..9e9e16f --- /dev/null +++ b/src/middleware/isMentorOrAdmin.js @@ -0,0 +1,11 @@ +const { UnauthorizedError } = require('../helpers/error') +const { response } = require('../helpers/utils/wrapper'); +const { ERROR } = require('../helpers/http-status/status_code'); + +module.exports = function isMentorOrAdmin(req, res, next) { + if (req.role && (req.role === 'admin' || req.role === 'mentor')) { + return next(); + } + + return response(res, 'fail', { err: new UnauthorizedError('Akses ditolak: Hanya admin atau mentor yang dapat mengakses ini') }, 'Akses ditolak: Hanya admin atau mentor yang dapat mengakses ini', ERROR.UNAUTHORIZED); +} diff --git a/src/modules/admin/models/admin.model.js b/src/modules/admin/models/admin.model.js index e48eb80..a9841c5 100755 --- a/src/modules/admin/models/admin.model.js +++ b/src/modules/admin/models/admin.model.js @@ -30,9 +30,9 @@ const createAdminModel = joi.object().keys({ 'any.required': 'Password wajib diisi.', }), - role: joi.string().valid('admin').default('admin').messages({ + role: joi.string().valid('admin', 'mentor').default('mentor').messages({ 'string.base': 'Role harus berupa teks.', - 'any.only': 'Role hanya boleh "admin".', + 'any.only': 'Role hanya boleh "admin" atau "mentor".', }), signature: joi.string().optional().allow('', null).messages({ diff --git a/src/modules/auth/services/auth.service.js b/src/modules/auth/services/auth.service.js index 711974a..c6b0bcb 100755 --- a/src/modules/auth/services/auth.service.js +++ b/src/modules/auth/services/auth.service.js @@ -86,7 +86,7 @@ class AuthService { async getCurrentUser(userId, role) { try { - if (role === 'admin') { + if (role === 'admin' || role === 'mentor') { const result = await adminService.getAdminById(userId); if (result.err) { @@ -97,7 +97,7 @@ class AuthService { return data({ ...adminWithoutPassword, - role: 'admin' + role: adminWithoutPassword.role }); } else if (role === 'intern') { const user = await prisma.user.findUnique({ @@ -151,7 +151,7 @@ class AuthService { const existingAdmin = await prisma.admin.findUnique({ where: { email } }); - if (existingAdmin && (role !== 'admin' || existingAdmin.id !== parseInt(userId))) { + if (existingAdmin && ((role !== 'admin' && role !== 'mentor') || existingAdmin.id !== parseInt(userId))) { if (imageFile?.filename) { const { deleteFileIfExists } = require('../../../helpers/utils/fileHelper'); await deleteFileIfExists(imageFile.filename).catch(() => { }); @@ -176,7 +176,7 @@ class AuthService { hashedPassword = await bcrypt.hash(password, 10); } - if (role === 'admin') { + if (role === 'admin' || role === 'mentor') { const adminId = parseInt(userId); const existingAdmin = await prisma.admin.findUnique({ where: { id: adminId } @@ -220,7 +220,7 @@ class AuthService { const { password: _, ...adminWithoutPassword } = updatedAdmin; return data({ ...adminWithoutPassword, - role: 'admin' + role: adminWithoutPassword.role }); } else if (role === 'intern') { diff --git a/src/modules/lamaranMagang/services/lamaranMagang.service.js b/src/modules/lamaranMagang/services/lamaranMagang.service.js index 56db474..873e02e 100755 --- a/src/modules/lamaranMagang/services/lamaranMagang.service.js +++ b/src/modules/lamaranMagang/services/lamaranMagang.service.js @@ -1,347 +1,417 @@ -const lamaranMagangRepository = require('../repositories/lamaranMagang.repository'); -const mahasiswaRepository = require('../../mahasiswa/repositories/mahasiswa.repository'); -const lowonganMagangRepository = require('../../lowonganMagang/repositories/lowonganMagang.repository'); -const batchRepository = require('../../batch/repositories/batch.repository'); -const bcrypt = require('bcrypt'); -const prisma = require('../../../helpers/db/db_connection'); -const crypto = require('crypto'); -const imageUploadHelper = require('../../../helpers/utils/imageUpload.helper'); -const { sendConfirmationEmail, sendStatusEmail } = require('../helpers/email.helper'); -const ExcelJS = require('exceljs'); -const path = require('path'); -const { deleteFileIfExists } = require('../../../helpers/utils/fileHelper'); -const { data, error, paginationData } = require('../../../helpers/utils/wrapper'); -const { NotFoundError, ConflictError } = require('../../../helpers/error'); - -class LamaranMagangService { - async createLamaranMagang(id_lowongan_magang, mahasiswaFormData, uploadedFiles) { - try { - const lowonganMagang = await lowonganMagangRepository.findById(id_lowongan_magang); - - if (!lowonganMagang) { - return error(new NotFoundError('Lowongan Magang Tidak Ditemukan')); - } - - const batchId = lowonganMagang.batch?.id || null; - - const existingMahasiswa = await mahasiswaRepository.findByEmail(mahasiswaFormData.email); - - if (existingMahasiswa) { - - const existingLamaran = await lamaranMagangRepository.findAnyLamaranByMahasiswa( - existingMahasiswa.id - ); - - if (existingLamaran) { - const posisiYangSudahDilamar = existingLamaran.lowongan_magang?.posisi || 'posisi tertentu'; - return error(new ConflictError( - `Anda sudah pernah melamar untuk posisi ${posisiYangSudahDilamar}. Setiap pengguna hanya dapat melamar satu posisi.` - )); - } - - const lamaranData = { - id_mahasiswa: existingMahasiswa.id, - id_lowongan_magang, - batch: batchId, - status: 'diproses', - }; - - await lamaranMagangRepository.create(lamaranData); - - return data({ - id_mahasiswa: existingMahasiswa.id, - id_lowongan_magang, - batch: batchId, - status: 'diproses', - }); - } - - const cv_path = imageUploadHelper.uploadCV(uploadedFiles.cv); - const portofolio_path = imageUploadHelper.uploadPortofolio(uploadedFiles.portofolio); - - const namaBelakangFinal = mahasiswaFormData.nama_belakang?.trim() || null; - - const mahasiswaData = { - nama_depan: mahasiswaFormData.nama_depan, - nama_belakang: namaBelakangFinal, - email: mahasiswaFormData.email, - kontak: mahasiswaFormData.kontak, - jurusan: mahasiswaFormData.jurusan, - universitas: mahasiswaFormData.universitas, - negara: mahasiswaFormData.negara, - motivasi: mahasiswaFormData.motivasi, - relevant_skills: mahasiswaFormData.relevant_skills, - role: 'student', - cv_path, - portofolio_path, - }; - - const createdMahasiswa = await mahasiswaRepository.create(mahasiswaData); - const id_mahasiswa = createdMahasiswa.id; - - const lamaranData = { - id_mahasiswa, - id_lowongan_magang, - batch: batchId, - status: 'diproses', - }; - - await lamaranMagangRepository.create(lamaranData); - - await sendConfirmationEmail(mahasiswaData, lowonganMagang); - - return data({ - id_mahasiswa, - id_lowongan_magang, - batch: batchId, - status: 'diproses', - }); - } catch (err) { - return error(err); - } - } - - async getAllLamaranMagang(query = {}) { - try { - const { page = 1, limit = 10, posisi, status, universitas, id_lowongan } = query; - const offset = (page - 1) * limit; - const lamaranList = await lamaranMagangRepository.findAll({ offset, limit, status, posisi, universitas, id_lowongan }); - const total = await lamaranMagangRepository.countAllLamaran({ status, posisi, universitas, id_lowongan }); - - const totalPages = Math.ceil(total / limit); - const meta = { page: Number(page), limit: Number(limit), total, totalPages }; - return paginationData(lamaranList, meta); - } catch (err) { - return error(err); - } - } - - async getLamaranById(id_lamaran_magang) { - try { - const lamaran = await lamaranMagangRepository.findById(id_lamaran_magang); - - if (!lamaran) { - return error(new NotFoundError('Lamaran Magang Tidak Ditemukan')); - } - - return data(lamaran); - } catch (err) { - return error(err); - } - } - - async getDetailLamaranById(id_lamaran) { - try { - const lamaran = await lamaranMagangRepository.findDetailById(id_lamaran); - - if (!lamaran) { - return error(new NotFoundError('Lamaran Magang Tidak Ditemukan')); - } - - return data(lamaran); - } catch (err) { - return error(err); - } - } - - async updateLamaranStatus(id_lamaran_magang, status) { - try { - const checkResult = await this.getDetailLamaranById(id_lamaran_magang); - - if (checkResult.err) { - return checkResult; - } - - const result = await lamaranMagangRepository.updateStatus(id_lamaran_magang, status); - - let generatedPassword = null; - if (status === 'diterima') { - const existingUser = await prisma.user.findFirst({ - where: { - OR: [ - { id_mahasiswa: checkResult.data.id_mahasiswa }, - { id_lamaran_magang: parseInt(id_lamaran_magang) }, - { email: checkResult.data.mahasiswa.email } - ] - } - }); - - // Akan dibuat akunnya ketika sudah ACC ama mentor dengan password yang random [Rafi 08/06/2026 16:05] - if (!existingUser) { - generatedPassword = crypto.randomBytes(4).toString('hex'); - const hashedPassword = await bcrypt.hash(generatedPassword, 10); - - const fullName = `${checkResult.data.mahasiswa.nama_depan}${checkResult.data.mahasiswa.nama_belakang ? ' ' + checkResult.data.mahasiswa.nama_belakang : ''}`; - - await prisma.user.create({ - data: { - id_mahasiswa: checkResult.data.id_mahasiswa, - id_lamaran_magang: parseInt(id_lamaran_magang), - full_name: fullName, - email: checkResult.data.mahasiswa.email, - password: hashedPassword, - role: 'intern', - is_active: true - } - }); - } - } - - await sendStatusEmail(checkResult.data, status, generatedPassword); - - return data(result); - } catch (err) { - return error(err); - } - } - - async deleteLamaran(id) { - try { - const checkResult = await this.getDetailLamaranById(id); - if (checkResult.err) { - return checkResult; - } - - const lamaran = checkResult.data; - - if (lamaran.cv_path) { - const cvFileName = path.basename(lamaran.cv_path); - await deleteFileIfExists(cvFileName); - } - - if (lamaran.portofolio_path) { - const portofolioFileName = path.basename(lamaran.portofolio_path); - await deleteFileIfExists(portofolioFileName); - } - - const result = await lamaranMagangRepository.deleteById(id); - return data(result); - } catch (err) { - return error(err); - } - } - - async exportToExcel() { - try { - const lamaranList = await lamaranMagangRepository.findAll({}); - - if (!lamaranList || lamaranList.length === 0) { - return error(new NotFoundError("Tidak ada data lamaran untuk diekspor")); - } - - const workbook = new ExcelJS.Workbook(); - const worksheet = workbook.addWorksheet('Lamaran Magang'); - - worksheet.columns = [ - { header: 'ID', key: 'id', width: 10 }, - { header: 'Nama Depan', key: 'nama_depan', width: 20 }, - { header: 'Nama Belakang', key: 'nama_belakang', width: 20 }, - { header: 'Posisi', key: 'posisi', width: 25 }, - { header: 'Kelompok Peminatan', key: 'kelompok_peminatan', width: 25 }, - { header: 'Status', key: 'status', width: 15 }, - { header: 'Tanggal Dibuat', key: 'created_at', width: 20 }, - ]; - - lamaranList.forEach((lamaran) => { - worksheet.addRow({ - id: lamaran.id, - nama_depan: lamaran.mahasiswa?.nama_depan || '', - nama_belakang: lamaran.mahasiswa?.nama_belakang || '', - posisi: lamaran.lowongan_magang?.posisi || '', - kelompok_peminatan: lamaran.lowongan_magang?.kelompok_peminatan || '', - status: lamaran.status, - created_at: lamaran.created_at, - }); - }); - - worksheet.getRow(1).font = { bold: true }; - worksheet.getRow(1).fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFD3D3D3' }, - }; - - return data(workbook); - } catch (err) { - return error(err); - } - } - - async getDashboardStatistics(query) { - try { - const batch = await batchRepository.getBatchById(query.batch); - const batchDisplay = batch ? batch.batch_number : 'Data Seluruh Batch'; - const stats = await lamaranMagangRepository.getStatistics({ query }); - - const acceptanceRate = stats.total > 0 ? ((stats.diterima / stats.total) * 100).toFixed(1) : 0; - const rejectionRate = stats.total > 0 ? ((stats.ditolak / stats.total) * 100).toFixed(1) : 0; - const pendingRate = stats.total > 0 ? ((stats.diproses / stats.total) * 100).toFixed(1) : 0; - - if (stats.total === 0) { - return error(new NotFoundError(`Tidak ada data lamaran ditemukan pada batch ${batchDisplay}`)); - } - - return data({ - total_pendaftar: stats.total, - total_diterima: stats.diterima, - total_ditolak: stats.ditolak, - sedang_diproses: stats.diproses, - presentase_diterima: `${acceptanceRate}%`, - presentase_ditolak: `${rejectionRate}%`, - presentase_diproses: `${pendingRate}%`, - batch: batchDisplay, - }); - } catch (err) { - return error(err); - } - } - - async getStatisticsByPosition(query) { - try { - const batch = await batchRepository.getBatchById(query.batch); - const batchDisplay = batch ? batch.batch_number : 'semua batch'; - const positions = await lamaranMagangRepository.getStatisticsByPosition({ query }); - - if (positions.length === 0) { - return error(new NotFoundError(`Tidak ada data lamaran ditemukan pada batch ${batchDisplay}`)); - } - - return data(positions); - } catch (err) { - return error(err); - } - } - - async getStatisticsByCountry(query) { - try { - const batch = await batchRepository.getBatchById(query.batch); - const batchDisplay = batch ? batch.batch_number : 'semua batch'; - const countries = await lamaranMagangRepository.getStatisticsByCountry({ query }); - if (countries.length === 0) { - return error(new NotFoundError(`Tidak ada data lamaran ditemukan pada batch ${batchDisplay}`)); - } - - return data(countries); - } catch (err) { - return error(err); - } - } - - async getStatisticsByUniversity(query) { - try { - const batch = await batchRepository.getBatchById(query.batch); - const batchDisplay = batch ? batch.batch_number : 'semua batch'; - const universities = await lamaranMagangRepository.getStatisticsByUniversity({ query }); - if (universities.length === 0) { - return error(new NotFoundError(`Tidak ada data lamaran ditemukan pada batch ${batchDisplay}`)); - } - return data(universities); - } catch (err) { - return error(err); - } - } -} - - - -module.exports = new LamaranMagangService(); +const lamaranMagangRepository = require("../repositories/lamaranMagang.repository"); +const mahasiswaRepository = require("../../mahasiswa/repositories/mahasiswa.repository"); +const lowonganMagangRepository = require("../../lowonganMagang/repositories/lowonganMagang.repository"); +const batchRepository = require("../../batch/repositories/batch.repository"); +const bcrypt = require("bcrypt"); +const prisma = require("../../../helpers/db/db_connection"); +const crypto = require("crypto"); +const imageUploadHelper = require("../../../helpers/utils/imageUpload.helper"); +const { + sendConfirmationEmail, + sendStatusEmail, +} = require("../helpers/email.helper"); +const ExcelJS = require("exceljs"); +const path = require("path"); +const { deleteFileIfExists } = require("../../../helpers/utils/fileHelper"); +const { + data, + error, + paginationData, +} = require("../../../helpers/utils/wrapper"); +const { NotFoundError, ConflictError } = require("../../../helpers/error"); + +class LamaranMagangService { + async createLamaranMagang( + id_lowongan_magang, + mahasiswaFormData, + uploadedFiles, + ) { + try { + const lowonganMagang = + await lowonganMagangRepository.findById(id_lowongan_magang); + + if (!lowonganMagang) { + return error(new NotFoundError("Lowongan Magang Tidak Ditemukan")); + } + + const batchId = lowonganMagang.batch?.id || null; + + const existingMahasiswa = await mahasiswaRepository.findByEmail( + mahasiswaFormData.email, + ); + + if (existingMahasiswa) { + const existingLamaran = + await lamaranMagangRepository.findAnyLamaranByMahasiswa( + existingMahasiswa.id, + ); + + if (existingLamaran) { + const posisiYangSudahDilamar = + existingLamaran.lowongan_magang?.posisi || "posisi tertentu"; + return error( + new ConflictError( + `Anda sudah pernah melamar untuk posisi ${posisiYangSudahDilamar}. Setiap pengguna hanya dapat melamar satu posisi.`, + ), + ); + } + + const lamaranData = { + id_mahasiswa: existingMahasiswa.id, + id_lowongan_magang, + batch: batchId, + status: "diproses", + }; + + await lamaranMagangRepository.create(lamaranData); + + return data({ + id_mahasiswa: existingMahasiswa.id, + id_lowongan_magang, + batch: batchId, + status: "diproses", + }); + } + + const cv_path = imageUploadHelper.uploadCV(uploadedFiles.cv); + const portofolio_path = imageUploadHelper.uploadPortofolio( + uploadedFiles.portofolio, + ); + + const namaBelakangFinal = mahasiswaFormData.nama_belakang?.trim() || null; + + const mahasiswaData = { + nama_depan: mahasiswaFormData.nama_depan, + nama_belakang: namaBelakangFinal, + email: mahasiswaFormData.email, + kontak: mahasiswaFormData.kontak, + jurusan: mahasiswaFormData.jurusan, + universitas: mahasiswaFormData.universitas, + negara: mahasiswaFormData.negara, + motivasi: mahasiswaFormData.motivasi, + relevant_skills: mahasiswaFormData.relevant_skills, + role: "student", + cv_path, + portofolio_path, + }; + + const createdMahasiswa = await mahasiswaRepository.create(mahasiswaData); + const id_mahasiswa = createdMahasiswa.id; + + const lamaranData = { + id_mahasiswa, + id_lowongan_magang, + batch: batchId, + status: "diproses", + }; + + await lamaranMagangRepository.create(lamaranData); + + await sendConfirmationEmail(mahasiswaData, lowonganMagang); + + return data({ + id_mahasiswa, + id_lowongan_magang, + batch: batchId, + status: "diproses", + }); + } catch (err) { + return error(err); + } + } + + async getAllLamaranMagang(query = {}) { + try { + const { + page = 1, + limit = 10, + posisi, + status, + universitas, + id_lowongan, + } = query; + const offset = (page - 1) * limit; + const lamaranList = await lamaranMagangRepository.findAll({ + offset, + limit, + status, + posisi, + universitas, + id_lowongan, + }); + const total = await lamaranMagangRepository.countAllLamaran({ + status, + posisi, + universitas, + id_lowongan, + }); + + const totalPages = Math.ceil(total / limit); + const meta = { + page: Number(page), + limit: Number(limit), + total, + totalPages, + }; + return paginationData(lamaranList, meta); + } catch (err) { + return error(err); + } + } + + async getLamaranById(id_lamaran_magang) { + try { + const lamaran = await lamaranMagangRepository.findById(id_lamaran_magang); + + if (!lamaran) { + return error(new NotFoundError("Lamaran Magang Tidak Ditemukan")); + } + + return data(lamaran); + } catch (err) { + return error(err); + } + } + + async getDetailLamaranById(id_lamaran) { + try { + const lamaran = await lamaranMagangRepository.findDetailById(id_lamaran); + + if (!lamaran) { + return error(new NotFoundError("Lamaran Magang Tidak Ditemukan")); + } + + return data(lamaran); + } catch (err) { + return error(err); + } + } + + async updateLamaranStatus(id_lamaran_magang, status) { + try { + const checkResult = await this.getDetailLamaranById(id_lamaran_magang); + + if (checkResult.err) { + return checkResult; + } + + const result = await lamaranMagangRepository.updateStatus( + id_lamaran_magang, + status, + ); + + let generatedPassword = null; + if (status === "diterima") { + const existingUser = await prisma.user.findFirst({ + where: { + OR: [ + { id_mahasiswa: checkResult.data.id_mahasiswa }, + { id_lamaran_magang: parseInt(id_lamaran_magang) }, + { email: checkResult.data.mahasiswa.email }, + ], + }, + }); + + // Akan dibuat akunnya ketika sudah ACC ama mentor dengan password yang random [Rafi 08/06/2026 16:05] + if (!existingUser) { + generatedPassword = crypto.randomBytes(4).toString("hex"); + const hashedPassword = await bcrypt.hash(generatedPassword, 10); + + const fullName = `${checkResult.data.mahasiswa.nama_depan}${checkResult.data.mahasiswa.nama_belakang ? " " + checkResult.data.mahasiswa.nama_belakang : ""}`; + + await prisma.user.create({ + data: { + id_mahasiswa: checkResult.data.id_mahasiswa, + id_lamaran_magang: parseInt(id_lamaran_magang), + full_name: fullName, + email: checkResult.data.mahasiswa.email, + password: hashedPassword, + role: "intern", + is_active: true, + }, + }); + } + } + + await sendStatusEmail(checkResult.data, status, generatedPassword); + + return data(result); + } catch (err) { + return error(err); + } + } + + async deleteLamaran(id) { + try { + const checkResult = await this.getDetailLamaranById(id); + if (checkResult.err) { + return checkResult; + } + + const lamaran = checkResult.data; + + if (lamaran.cv_path) { + const cvFileName = path.basename(lamaran.cv_path); + await deleteFileIfExists(cvFileName); + } + + if (lamaran.portofolio_path) { + const portofolioFileName = path.basename(lamaran.portofolio_path); + await deleteFileIfExists(portofolioFileName); + } + + const result = await lamaranMagangRepository.deleteById(id); + return data(result); + } catch (err) { + return error(err); + } + } + + async exportToExcel() { + try { + const lamaranList = await lamaranMagangRepository.findAll({}); + + if (!lamaranList || lamaranList.length === 0) { + return error( + new NotFoundError("Tidak ada data lamaran untuk diekspor"), + ); + } + + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("Lamaran Magang"); + + worksheet.columns = [ + { header: "ID", key: "id", width: 10 }, + { header: "Nama Depan", key: "nama_depan", width: 20 }, + { header: "Nama Belakang", key: "nama_belakang", width: 20 }, + { header: "Posisi", key: "posisi", width: 25 }, + { header: "Kelompok Peminatan", key: "kelompok_peminatan", width: 25 }, + { header: "Status", key: "status", width: 15 }, + { header: "Tanggal Dibuat", key: "created_at", width: 20 }, + ]; + + lamaranList.forEach((lamaran) => { + worksheet.addRow({ + id: lamaran.id, + nama_depan: lamaran.mahasiswa?.nama_depan || "", + nama_belakang: lamaran.mahasiswa?.nama_belakang || "", + posisi: lamaran.lowongan_magang?.posisi || "", + kelompok_peminatan: lamaran.lowongan_magang?.kelompok_peminatan || "", + status: lamaran.status, + created_at: lamaran.created_at, + }); + }); + + worksheet.getRow(1).font = { bold: true }; + worksheet.getRow(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFD3D3D3" }, + }; + + return data(workbook); + } catch (err) { + return error(err); + } + } + + async getDashboardStatistics(query) { + try { + const batch = await batchRepository.getBatchById(query.batch); + const batchDisplay = batch ? batch.batch_number : "Data Seluruh Batch"; + const stats = await lamaranMagangRepository.getStatistics({ query }); + + const acceptanceRate = + stats.total > 0 ? ((stats.diterima / stats.total) * 100).toFixed(1) : 0; + const rejectionRate = + stats.total > 0 ? ((stats.ditolak / stats.total) * 100).toFixed(1) : 0; + const pendingRate = + stats.total > 0 ? ((stats.diproses / stats.total) * 100).toFixed(1) : 0; + + if (stats.total === 0) { + return error( + new NotFoundError( + `Tidak ada data lamaran ditemukan pada batch ${batchDisplay}`, + ), + ); + } + + return data({ + total_pendaftar: stats.total, + total_diterima: stats.diterima, + total_ditolak: stats.ditolak, + sedang_diproses: stats.diproses, + presentase_diterima: `${acceptanceRate}%`, + presentase_ditolak: `${rejectionRate}%`, + presentase_diproses: `${pendingRate}%`, + batch: batchDisplay, + }); + } catch (err) { + return error(err); + } + } + + async getStatisticsByPosition(query) { + try { + const batch = await batchRepository.getBatchById(query.batch); + const batchDisplay = batch ? batch.batch_number : "semua batch"; + const positions = await lamaranMagangRepository.getStatisticsByPosition({ + query, + }); + + if (positions.length === 0) { + return error( + new NotFoundError( + `Tidak ada data lamaran ditemukan pada batch ${batchDisplay}`, + ), + ); + } + + return data(positions); + } catch (err) { + return error(err); + } + } + + async getStatisticsByCountry(query) { + try { + const batch = await batchRepository.getBatchById(query.batch); + const batchDisplay = batch ? batch.batch_number : "semua batch"; + const countries = await lamaranMagangRepository.getStatisticsByCountry({ + query, + }); + if (countries.length === 0) { + return error( + new NotFoundError( + `Tidak ada data lamaran ditemukan pada batch ${batchDisplay}`, + ), + ); + } + + return data(countries); + } catch (err) { + return error(err); + } + } + + async getStatisticsByUniversity(query) { + try { + const batch = await batchRepository.getBatchById(query.batch); + const batchDisplay = batch ? batch.batch_number : "semua batch"; + const universities = + await lamaranMagangRepository.getStatisticsByUniversity({ query }); + if (universities.length === 0) { + return error( + new NotFoundError( + `Tidak ada data lamaran ditemukan pada batch ${batchDisplay}`, + ), + ); + } + return data(universities); + } catch (err) { + return error(err); + } + } +} + +module.exports = new LamaranMagangService(); diff --git a/src/modules/project/repositories/project.repository.js b/src/modules/project/repositories/project.repository.js index 5b941cf..38ec6e9 100644 --- a/src/modules/project/repositories/project.repository.js +++ b/src/modules/project/repositories/project.repository.js @@ -74,6 +74,10 @@ class ProjectRepository { }; } + if (actor?.role === "mentor") { + where.id_admin = parseInt(actor.id); + } + return prisma.project.findMany({ where, orderBy: { diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index d1775d1..594bdef 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -14,9 +14,9 @@ class ProjectService { return error(new BadRequestError("Admin ID is required")); } - if (actor.role !== "admin") { + if (actor.role !== "admin" && actor.role !== "mentor") { return error( - new ForbiddenError("Access denied: only admin can create project"), + new ForbiddenError("Access denied: only admin or mentor can create project"), ); } @@ -65,7 +65,6 @@ class ProjectService { description: projectData.description || "", start_date: projectData.start_date, end_date: projectData.end_date, - max_members: 8, status: "active", }; @@ -95,7 +94,6 @@ class ProjectService { description: project.description, start_date: project.start_date, end_date: project.end_date, - max_members: project.max_members, status: project.status, admin: this.mapAdmin(project.admin), total_members: project.members?.length || 0, @@ -135,9 +133,9 @@ class ProjectService { return error(new BadRequestError("Admin ID is required")); } - if (actor.role !== "admin") { + if (actor.role !== "admin" && actor.role !== "mentor") { return error( - new ForbiddenError("Access denied: only admin can update project"), + new ForbiddenError("Access denied: only admin or mentor can update project"), ); } @@ -188,9 +186,9 @@ class ProjectService { return error(new BadRequestError("Admin ID is required")); } - if (actor.role !== "admin") { + if (actor.role !== "admin" && actor.role !== "mentor") { return error( - new ForbiddenError("Access denied: only admin can archive project"), + new ForbiddenError("Access denied: only admin or mentor can archive project"), ); } @@ -223,6 +221,16 @@ class ProjectService { return null; } + if (actor.role === "mentor") { + const project = await projectRepository.findById(idProject); + if (!project || project.id_admin !== parseInt(actor.id)) { + return new ForbiddenError( + "Access denied: you are not the mentor of this project", + ); + } + return null; + } + if (actor.role === "intern") { const membership = await projectRepository.checkActiveMember( idProject, @@ -259,7 +267,6 @@ class ProjectService { description: project.description, start_date: project.start_date, end_date: project.end_date, - max_members: project.max_members, status: project.status, admin: this.mapAdmin(project.admin), members: @@ -328,7 +335,6 @@ class ProjectService { description: project.description, start_date: project.start_date, end_date: project.end_date, - max_members: project.max_members, status: project.status, admin: this.mapAdmin(project.admin), total_members: project.members?.length || 0, @@ -393,7 +399,7 @@ class ProjectService { return error(new BadRequestError("Admin ID is required")); } - if (actor.role !== "admin") { + if (actor.role !== "admin" && actor.role !== "mentor") { return error( new ForbiddenError("Access denied: only mentors/admins can view their created projects"), ); @@ -408,7 +414,6 @@ class ProjectService { description: project.description, start_date: project.start_date, end_date: project.end_date, - max_members: project.max_members, status: project.status, admin: this.mapAdmin(project.admin), total_members: project.members?.length || 0, @@ -429,7 +434,7 @@ class ProjectService { return error(new BadRequestError("Admin ID is required")); } - if (actor.role !== "admin") { + if (actor.role !== "admin" && actor.role !== "mentor") { return error( new ForbiddenError("Access denied: only admin/mentor can access interns list"), ); @@ -465,7 +470,7 @@ class ProjectService { return error(new BadRequestError("Admin ID is required")); } - if (actor.role !== "admin") { + if (actor.role !== "admin" && actor.role !== "mentor") { return error( new ForbiddenError("Access denied: only mentors/admins can assign members"), ); diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 3bb9224..0f7435b 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -2,6 +2,7 @@ const express = require('express'); const { projectController } = require('../modules/project'); const { verifyJWT } = require('../middleware/verifyJWT'); const isAdmin = require('../middleware/isAdmin'); +const isMentorOrAdmin = require('../middleware/isMentorOrAdmin'); const router = express.Router(); @@ -33,9 +34,6 @@ const router = express.Router(); * type: string * format: date * example: 2026-08-10 - * max_members: - * type: integer - * example: 8 * status: * type: string * enum: [active, completed, archived] @@ -103,6 +101,352 @@ 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 + * 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 */ /** @@ -136,7 +480,7 @@ const router = express.Router(); * 500: * description: Internal server error */ -router.post('/add', verifyJWT, isAdmin, projectController.createProject); +router.post('/add', verifyJWT, isMentorOrAdmin, projectController.createProject); /** * @swagger @@ -238,7 +582,7 @@ router.get('/get/:id', verifyJWT, projectController.getProjectById); * 500: * description: Internal server error */ -router.patch('/update/:id', verifyJWT, isAdmin, projectController.updateProject); +router.patch('/update/:id', verifyJWT, isMentorOrAdmin, projectController.updateProject); /** * @swagger @@ -269,7 +613,7 @@ router.patch('/update/:id', verifyJWT, isAdmin, projectController.updateProject) * 500: * description: Internal server error */ -router.delete('/delete/:id', verifyJWT, isAdmin, projectController.archiveProject); +router.delete('/delete/:id', verifyJWT, isMentorOrAdmin, projectController.archiveProject); /** * @swagger @@ -332,7 +676,7 @@ router.get('/my-tasks', verifyJWT, projectController.getMyTasks); * 500: * description: Internal server error */ -router.get('/mentor-projects', verifyJWT, isAdmin, projectController.getMentorProjects); +router.get('/mentor-projects', verifyJWT, isMentorOrAdmin, projectController.getMentorProjects); /** * @swagger @@ -391,7 +735,7 @@ router.get('/mentor-projects', verifyJWT, isAdmin, projectController.getMentorPr * 500: * description: Internal server error */ -router.get('/interns', verifyJWT, isAdmin, projectController.getInterns); +router.get('/interns', verifyJWT, isMentorOrAdmin, projectController.getInterns); /** * @swagger @@ -434,6 +778,6 @@ router.get('/interns', verifyJWT, isAdmin, projectController.getInterns); * 500: * description: Internal server error */ -router.post('/assign-member', verifyJWT, isAdmin, projectController.assignMember); +router.post('/assign-member', verifyJWT, isMentorOrAdmin, projectController.assignMember); module.exports = router; diff --git a/test/test_mentor_admin.js b/test/test_mentor_admin.js new file mode 100644 index 0000000..43b1123 --- /dev/null +++ b/test/test_mentor_admin.js @@ -0,0 +1,122 @@ +const axios = require('axios'); + +const BASE_URL = 'http://localhost:9000'; + +async function runTests() { + console.log('=== STARTING INTEGRATION TESTS FOR ADMIN & MENTOR ROLES ===\n'); + + let adminToken = ''; + let mentorToken = ''; + let createdProjectId = null; + const mentorEmail = `mentor_${Date.now()}@internify.com`; + const mentorPassword = 'Password123'; + + // Helper config generator + const getHeader = (token) => ({ + headers: { Authorization: `Bearer ${token}` } + }); + + try { + // 1. Login as Admin + console.log('1. Logging in as Admin (admin1@internify.com)...'); + const loginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: 'admin1@internify.com', + password: 'password123' + }); + + adminToken = loginRes.data.data.token; + console.log(' Admin logged in successfully!\n'); + + // 2. Create Mentor Account via Admin API + console.log(`2. Creating a new Mentor account (${mentorEmail}) via Admin endpoint...`); + await axios.post(`${BASE_URL}/admin-api/add`, { + nama_depan: 'John', + nama_belakang: 'Mentor', + email: mentorEmail, + password: mentorPassword, + role: 'mentor' + }, getHeader(adminToken)); + + console.log(' Mentor account created successfully!\n'); + + // 3. Login as Mentor + console.log('3. Logging in as the newly created Mentor...'); + const mentorLoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: mentorEmail, + password: mentorPassword + }); + + mentorToken = mentorLoginRes.data.data.token; + console.log(` Mentor logged in successfully! Role: ${mentorLoginRes.data.data.user.role}\n`); + + // 4. Mentor creates a project + console.log('4. Creating a project as Mentor...'); + const createProjectRes = await axios.post(`${BASE_URL}/project-api/add`, { + project_icon: 'code', + project_name: `Mentor's Custom Project - ${Date.now()}`, + description: 'A project created specifically to test mentor ownership isolation.', + start_date: '2026-07-01', + end_date: '2026-10-01', + member_emails: [] + }, getHeader(mentorToken)); + + createdProjectId = createProjectRes.data.data.id; + console.log(` Project created successfully! ID: ${createdProjectId}\n`); + + // 5. Mentor lists projects (should ONLY see their own created project) + console.log('5. Mentor lists their projects...'); + const mentorProjectsRes = await axios.get(`${BASE_URL}/project-api/get`, getHeader(mentorToken)); + const mentorProjects = mentorProjectsRes.data.data; + console.log(` Projects found for mentor: ${mentorProjects.length}`); + mentorProjects.forEach(p => console.log(` - [ID: ${p.id}] ${p.project_name} (Created by Admin/Mentor ID: ${p.admin.id})`)); + + const hasOtherProjects = mentorProjects.some(p => p.id !== createdProjectId); + if (hasOtherProjects) { + throw new Error('TEST FAILED: Mentor is seeing projects they did not create!'); + } + console.log(' PASSED: Mentor only sees their own project.\n'); + + // 6. Admin lists projects (should see all projects, including the mentor\'s project and seeded projects) + console.log('6. Admin lists all projects...'); + const adminProjectsRes = await axios.get(`${BASE_URL}/project-api/get`, getHeader(adminToken)); + const adminProjects = adminProjectsRes.data.data; + console.log(` Projects found for admin: ${adminProjects.length}`); + + const containsMentorProject = adminProjects.some(p => p.id === createdProjectId); + if (!containsMentorProject) { + throw new Error('TEST FAILED: Admin cannot see the project created by the mentor!'); + } + console.log(' PASSED: Admin can see all projects in the system.\n'); + + // 7. Mentor tries to access another project details (e.g. Project 1, created by admin1) + console.log('7. Testing isolation: Mentor trying to access Project ID 1 (which they do not own)...'); + try { + await axios.get(`${BASE_URL}/project-api/get/1`, getHeader(mentorToken)); + throw new Error('TEST FAILED: Mentor successfully accessed another mentor/admin\'s project!'); + } catch (err) { + if (err.response && err.response.status === 403) { + console.log(' PASSED: Access denied with 403 Forbidden as expected.\n'); + } else { + throw err; + } + } + + // 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`); + + console.log('=== ALL INTEGRATION TESTS PASSED SUCCESSFULLY! ==='); + } catch (err) { + console.error('=== 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.message); + } + process.exit(1); + } +} + +runTests();