diff --git a/package-lock.json b/package-lock.json index eb989bd..e7dfb6b 100755 --- a/package-lock.json +++ b/package-lock.json @@ -2961,6 +2961,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.16.2", "@prisma/engines": "6.16.2" diff --git a/package.json b/package.json index 26b097f..bc1b35b 100755 --- a/package.json +++ b/package.json @@ -37,5 +37,8 @@ }, "devDependencies": { "prisma": "^6.16.2" + }, + "prisma": { + "seed": "node prisma/seed.js" } } diff --git a/prisma/seed.js b/prisma/seed.js new file mode 100644 index 0000000..c05cbaf --- /dev/null +++ b/prisma/seed.js @@ -0,0 +1,222 @@ +const bcrypt = require('bcrypt'); +const { PrismaClient } = require('../src/generated/prisma'); +const prisma = new PrismaClient(); + +// Diperlukan ketika isi databasenya masih kosong dan ingin menambahkan dummy data sebagai testing development [Rafi 08/06/2026 16:34] +async function main() { + await prisma.taskSubmission.deleteMany({}); + await prisma.projectMember.deleteMany({}); + await prisma.user.deleteMany({}); + await prisma.task.deleteMany({}); + await prisma.lamaranMagang.deleteMany({}); + await prisma.project.deleteMany({}); + await prisma.lowonganMagang.deleteMany({}); + await prisma.mahasiswa.deleteMany({}); + await prisma.riwayatPelamar.deleteMany({}); + await prisma.feedback.deleteMany({}); + await prisma.faq.deleteMany({}); + await prisma.partnership.deleteMany({}); + await prisma.hasilResearch.deleteMany({}); + await prisma.batch.deleteMany({}); + await prisma.admin.deleteMany({}); + + const hashedPassword = await bcrypt.hash('password123', 10); + + const admin1 = await prisma.admin.create({ + data: { + nama_depan: 'Admin', + nama_belakang: 'One', + email: 'admin1@internify.com', + password: hashedPassword, + role: 'admin', + signature: `Admin-One-${Date.now()}`, + profile_picture: 'https://placehold.co/150', + professional_bio: 'Senior Program Manager at Internify.' + } + }); + const admin2 = await prisma.admin.create({ + data: { + nama_depan: 'Admin', + nama_belakang: 'Two', + email: 'admin2@internify.com', + password: hashedPassword, + role: 'admin', + signature: `Admin-Two-${Date.now()}`, + profile_picture: 'https://placehold.co/150', + professional_bio: 'Lead Engineering Mentor at Internify.' + } + }); + const admin3 = await prisma.admin.create({ + data: { + nama_depan: 'Admin', + nama_belakang: 'Three', + email: 'admin3@internify.com', + password: hashedPassword, + role: 'admin', + signature: `Admin-Three-${Date.now()}`, + profile_picture: 'https://placehold.co/150', + professional_bio: 'Design Mentor at Internify.' + } + }); + + const batch1 = await prisma.batch.create({ + data: { id: 1, batch_number: 1, is_active: false } + }); + const batch2 = await prisma.batch.create({ + data: { id: 2, batch_number: 2, is_active: false } + }); + const batch3 = await prisma.batch.create({ + data: { id: 3, batch_number: 3, is_active: true } + }); + + await prisma.hasilResearch.createMany({ + data: [ + { + nama_project: 'AI Intern Matcher', + deskripsi: 'Match students using machine learning algorithms', + image_path: 'https://placehold.co/600x400', + link_project: 'https://github.com/internify/ai-matcher' + }, + { + nama_project: 'Feedback Sentiment Analyzer', + deskripsi: 'Analyze student feedback sentiment trends', + image_path: 'https://placehold.co/600x400', + link_project: 'https://github.com/internify/sentiment' + }, + { + nama_project: 'LMS Portal Engine', + deskripsi: 'Modular learning management system backend', + image_path: 'https://placehold.co/600x400', + link_project: 'https://github.com/internify/lms' + } + ] + }); + + await prisma.partnership.createMany({ + data: [ + { nama_partner: 'Google Indonesia', image_path: 'https://placehold.co/150' }, + { nama_partner: 'Microsoft Indonesia', image_path: 'https://placehold.co/150' }, + { nama_partner: 'Meta APAC', image_path: 'https://placehold.co/150' } + ] + }); + + // 5. Faq + await prisma.faq.createMany({ + data: [ + { pertanyaan: 'Bagaimana cara mendaftar magang?', jawaban: 'Buka menu Lowongan Magang, pilih salah satu lowongan aktif, dan klik tombol daftar.' }, + { pertanyaan: 'Apakah program magang ini berbayar?', jawaban: 'Tergantung pada tipe lowongan (paid/unpaid). Detail tertera di tiap deskripsi lowongan.' }, + { pertanyaan: 'Berapa lama durasi program magang?', jawaban: 'Biasanya durasi magang berlangsung antara 3 hingga 6 bulan penuh.' } + ] + }); + + // 6. Feedback + await prisma.feedback.createMany({ + data: [ + { nama: 'Budi Utomo', universitas: 'Institut Teknologi Bandung', pesan: 'Pengalaman magang yang luar biasa! Mentornya ramah dan proyeknya menantang.', batch: 1, posisi: 'Web Developer', tahun: 2025, image_path: 'https://placehold.co/150' }, + { nama: 'Siti Aminah', universitas: 'Universitas Indonesia', pesan: 'Mendapatkan banyak wawasan praktis tentang UI/UX prototyping di industri nyata.', batch: 2, posisi: 'UI/UX Designer', tahun: 2025, image_path: 'https://placehold.co/150' }, + { nama: 'Andi Wijaya', universitas: 'Universitas Gadjah Mada', pesan: 'Sangat direkomendasikan bagi mahasiswa yang ingin belajar data engineering dengan benar.', batch: 2, posisi: 'Data Analyst', tahun: 2026, image_path: 'https://placehold.co/150' } + ] + }); + + // 7. RiwayatPelamar + await prisma.riwayatPelamar.createMany({ + data: [ + { batch: 1, negara: 'Indonesia', posisi: 'Web Developer', tahun: 2025, jumlah: 100, pelamar_diterima: 10, pelamar_ditolak: 70, pelamar_diproses: 20, presentase_diterima: 10.0, presentase_ditolak: 70.0, presentase_diproses: 20.0 }, + { batch: 2, negara: 'Indonesia', posisi: 'UI/UX Designer', tahun: 2025, jumlah: 150, pelamar_diterima: 15, pelamar_ditolak: 120, pelamar_diproses: 15, presentase_diterima: 10.0, presentase_ditolak: 80.0, presentase_diproses: 10.0 }, + { batch: 2, negara: 'Malaysia', posisi: 'Data Analyst', tahun: 2026, jumlah: 50, pelamar_diterima: 5, pelamar_ditolak: 40, pelamar_diproses: 5, presentase_diterima: 10.0, presentase_ditolak: 80.0, presentase_diproses: 10.0 } + ] + }); + + // 8. Mahasiswa + const mhs1 = await prisma.mahasiswa.create({ + data: { nama_depan: 'Rafi', nama_belakang: 'Athallah', email: 'rafi@student.com', kontak: '08123456789', jurusan: 'Teknik Informatika', universitas: 'ITS', negara: 'Indonesia', cv_path: 'https://example.com/cv-rafi.pdf', portofolio_path: 'https://example.com/portfolio-rafi.pdf', motivasi: 'Ingin mendalami Node.js', relevant_skills: 'Node.js, React, MySQL' } + }); + const mhs2 = await prisma.mahasiswa.create({ + data: { nama_depan: 'Alice', nama_belakang: 'Smith', email: 'alice@student.com', kontak: '08234567890', jurusan: 'Sistem Informasi', universitas: 'UI', negara: 'Indonesia', cv_path: 'https://example.com/cv-alice.pdf', portofolio_path: 'https://example.com/portfolio-alice.pdf', motivasi: 'Tertarik dengan UI/UX design', relevant_skills: 'Figma, CSS, React' } + }); + const mhs3 = await prisma.mahasiswa.create({ + data: { nama_depan: 'Bob', nama_belakang: 'Johnson', email: 'bob@student.com', kontak: '08345678901', jurusan: 'Ilmu Komputer', universitas: 'UGM', negara: 'Indonesia', cv_path: 'https://example.com/cv-bob.pdf', portofolio_path: 'https://example.com/portfolio-bob.pdf', motivasi: 'Ingin berkarir di data science', relevant_skills: 'Python, SQL, R' } + }); + + // 9. LowonganMagang + const lowongan1 = await prisma.lowonganMagang.create({ + data: { id: 'LOW-001', posisi: 'Web Developer', kelompok_peminatan: 'Software Engineering', image_path: 'https://placehold.co/600x400', jobdesk: 'Build REST APIs and user interfaces', lokasi: 'Remote', kualifikasi: 'Knowledge of Node.js & React', benefit: 'Certificate, stipend', durasi_awal: new Date('2026-07-01'), durasi_akhir: new Date('2026-12-31'), id_batch: batch3.id, status_lowongan: 'dibuka', paid: 'paid' } + }); + const lowongan2 = await prisma.lowonganMagang.create({ + data: { id: 'LOW-002', posisi: 'UI/UX Designer', kelompok_peminatan: 'Design', image_path: 'https://placehold.co/600x400', jobdesk: 'Create wireframes, mockups, and high-fidelity designs', lokasi: 'Hybrid', kualifikasi: 'Proficiency in Figma', benefit: 'Certificate, mentoring', durasi_awal: new Date('2026-07-01'), durasi_akhir: new Date('2026-12-31'), id_batch: batch3.id, status_lowongan: 'dibuka', paid: 'unpaid' } + }); + const lowongan3 = await prisma.lowonganMagang.create({ + data: { id: 'LOW-003', posisi: 'Data Analyst', kelompok_peminatan: 'Data Science', image_path: 'https://placehold.co/600x400', jobdesk: 'Analyze dataset and create interactive dashboard', lokasi: 'Onsite', kualifikasi: 'Skills in Python, SQL, Tableau', benefit: 'Certificate, meals', durasi_awal: new Date('2026-07-01'), durasi_akhir: new Date('2026-12-31'), id_batch: batch3.id, status_lowongan: 'dibuka', paid: 'paid' } + }); + + // 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' } + }); + 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' } + }); + 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' } + }); + + // 11. LamaranMagang + const lamaran1 = await prisma.lamaranMagang.create({ + data: { id_mahasiswa: mhs1.id, id_lowongan_magang: lowongan1.id, status: 'diterima', batch: batch3.id, update_at: new Date() } + }); + const lamaran2 = await prisma.lamaranMagang.create({ + data: { id_mahasiswa: mhs2.id, id_lowongan_magang: lowongan2.id, status: 'diterima', batch: batch3.id, update_at: new Date() } + }); + const lamaran3 = await prisma.lamaranMagang.create({ + data: { id_mahasiswa: mhs3.id, id_lowongan_magang: lowongan3.id, status: 'diterima', batch: batch3.id, update_at: new Date() } + }); + + // 12. Task + const task1 = await prisma.task.create({ + data: { id_project: proj1.id, title: 'Database Setup', description: 'Setup database schema and write initial seed script.', deadline_at: new Date('2026-08-01'), submission_type: 'url_link' } + }); + const task2 = await prisma.task.create({ + data: { id_project: proj1.id, title: 'API Specification', description: 'Draft comprehensive swagger API document.', deadline_at: new Date('2026-08-15'), submission_type: 'file_upload' } + }); + const task3 = await prisma.task.create({ + data: { id_project: proj2.id, title: 'Implement JWT Validation', description: 'Setup middleware and keys for JWT validation.', deadline_at: new Date('2026-08-10'), submission_type: 'url_link' } + }); + + // 13. User + const user1 = await prisma.user.create({ + data: { id_mahasiswa: mhs1.id, id_lamaran_magang: lamaran1.id, full_name: 'Rafi Athallah', email: 'rafi@student.com', password: hashedPassword, role: 'intern', is_active: true } + }); + const user2 = await prisma.user.create({ + data: { id_mahasiswa: mhs2.id, id_lamaran_magang: lamaran2.id, full_name: 'Alice Smith', email: 'alice@student.com', password: hashedPassword, role: 'intern', is_active: true } + }); + const user3 = await prisma.user.create({ + data: { id_mahasiswa: mhs3.id, id_lamaran_magang: lamaran3.id, full_name: 'Bob Johnson', email: 'bob@student.com', password: hashedPassword, role: 'intern', is_active: true } + }); + + // 14. ProjectMember + await prisma.projectMember.createMany({ + data: [ + { id_project: proj1.id, id_user: user1.id, status: 'active' }, + { id_project: proj1.id, id_user: user2.id, status: 'active' }, + { id_project: proj2.id, id_user: user3.id, status: 'active' } + ] + }); + + // 15. TaskSubmission + await prisma.taskSubmission.createMany({ + data: [ + { id_task: task1.id, id_user: user1.id, url_link: 'https://github.com/rafiathallah3' }, + { id_task: task2.id, id_user: user2.id, file_path: 'https://example.com/submission-alice.zip' }, + { id_task: task3.id, id_user: user3.id, url_link: 'https://github.com/bob/auth-gateway' } + ] + }); +} + +main() + .catch((e) => { + console.error('Error seeding data:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/middleware/isAdmin.js b/src/middleware/isAdmin.js index 10239e5..91c6568 100755 --- a/src/middleware/isAdmin.js +++ b/src/middleware/isAdmin.js @@ -7,5 +7,5 @@ module.exports = function isAdmin(req, res, next) { return next(); } - return response(res, 'fail', new UnauthorizedError('Akses ditolak: Hanya admin yang dapat mengakses ini'), 'Akses ditolak: Hanya admin yang dapat mengakses ini', ERROR.UNAUTHORIZED); + return response(res, 'fail', { err: new UnauthorizedError('Akses ditolak: Hanya admin yang dapat mengakses ini') }, 'Akses ditolak: Hanya admin yang dapat mengakses ini', ERROR.UNAUTHORIZED); } \ No newline at end of file diff --git a/src/modules/auth/controllers/auth.controller.js b/src/modules/auth/controllers/auth.controller.js index f223bb2..24fbd49 100755 --- a/src/modules/auth/controllers/auth.controller.js +++ b/src/modules/auth/controllers/auth.controller.js @@ -3,7 +3,9 @@ const { response, data, error } = require('../../../helpers/utils/wrapper'); const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code'); const { BadRequestError, UnauthorizedError, InternalServerError } = require('../../../helpers/error'); const { isValidPayload } = require('../../../helpers/utils/validator'); -const { loginModel } = require('../models/auth.model'); +const { loginModel, updateProfileModel } = require('../models/auth.model'); +const { deleteFileIfExists } = require('../../../helpers/utils/fileHelper'); + class AuthController { async login(req, res) { @@ -48,7 +50,7 @@ class AuthController { return response(res, 'fail', error(new UnauthorizedError('Tidak diotorisasi: ID pengguna tidak ditemukan'))); } - const result = await authService.getCurrentUser(id); + const result = await authService.getCurrentUser(id, req.role); if (result.err) { return response(res, 'fail', result); @@ -67,6 +69,48 @@ class AuthController { return response(res, 'fail', error(new InternalServerError(err.message)), 'Terjadi kesalahan yang tidak terduga', ERROR.INTERNAL_ERROR); } } + + async updateProfile(req, res) { + try { + const validatePayload = isValidPayload(req.body, updateProfileModel); + + if (validatePayload.err) { + if (req.file?.filename) { + await deleteFileIfExists(req.file.filename).catch(() => { }); + } + return response( + res, + 'fail', + { err: validatePayload.err, data: null }, + 'Data tidak valid', + ERROR.EXPECTATION_FAILED + ); + } + + const id = req.id; + const role = req.role; + + if (!id || !role) { + if (req.file?.filename) { + await deleteFileIfExists(req.file.filename).catch(() => { }); + } + return response(res, 'fail', error(new UnauthorizedError('Tidak diotorisasi: ID pengguna tidak ditemukan'))); + } + + const result = await authService.updateProfile(id, role, validatePayload.data, req.file); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Profil berhasil diperbarui', SUCCESS.OK); + } catch (err) { + if (req.file?.filename) { + await deleteFileIfExists(req.file.filename).catch(() => { }); + } + return response(res, 'fail', error(new InternalServerError(err.message)), 'Terjadi kesalahan yang tidak terduga', ERROR.INTERNAL_ERROR); + } + } } module.exports = new AuthController(); diff --git a/src/modules/auth/models/auth.model.js b/src/modules/auth/models/auth.model.js index 18b0669..99da4b6 100755 --- a/src/modules/auth/models/auth.model.js +++ b/src/modules/auth/models/auth.model.js @@ -66,9 +66,28 @@ const resetPasswordModel = joi.object().keys({ }), }); +const updateProfileModel = joi.object().keys({ + full_name: joi.string().max(255).optional().messages({ + 'string.base': 'Nama lengkap harus berupa teks.', + 'string.max': 'Nama lengkap tidak boleh lebih dari 255 karakter.' + }), + email: joi.string().email({ tlds: { allow: false } }).optional().messages({ + 'string.base': 'Email harus berupa teks.', + 'string.email': 'Format email tidak valid.' + }), + password: joi.string().min(8).optional().messages({ + 'string.base': 'Password harus berupa teks.', + 'string.min': 'Password minimal {#limit} karakter.' + }), + professional_bio: joi.string().allow('', null).optional().messages({ + 'string.base': 'Bio harus berupa teks.' + }) +}); + module.exports = { loginModel, registerModel, forgotPasswordModel, resetPasswordModel, + updateProfileModel, }; diff --git a/src/modules/auth/services/auth.service.js b/src/modules/auth/services/auth.service.js index 4c7cf59..711974a 100755 --- a/src/modules/auth/services/auth.service.js +++ b/src/modules/auth/services/auth.service.js @@ -2,58 +2,133 @@ const bcrypt = require('bcrypt'); const adminService = require('../../admin/services/admin.service'); const { createToken } = require('../../../middleware/verifyJWT'); const { data, error } = require('../../../helpers/utils/wrapper'); -const { UnauthorizedError, NotFoundError } = require('../../../helpers/error'); +const { UnauthorizedError, NotFoundError, ForbiddenError, ConflictError, BadRequestError } = require('../../../helpers/error'); +const prisma = require('../../../helpers/db/db_connection'); class AuthService { async login(email, password) { try { - const result = await adminService.getAdminByEmail(email); - if (result.err || !result.data) { + let userRecord = null; + let isUserAdmin = false; + + const adminResult = await adminService.getAdminByEmail(email); + if (adminResult.data) { + userRecord = adminResult.data; + isUserAdmin = true; + } else { + const internRecord = await prisma.user.findUnique({ + where: { + email: email, + is_active: true + } + }); + if (internRecord) { + userRecord = internRecord; + isUserAdmin = false; + } + } + + if (!userRecord) { return error(new UnauthorizedError('Email atau password tidak valid')); } - const admin = result.data; - - const isPasswordValid = await bcrypt.compare(password, admin.password); + const isPasswordValid = await bcrypt.compare(password, userRecord.password); if (!isPasswordValid) { return error(new UnauthorizedError('Email atau password tidak valid')); } - const token = createToken({ - id: admin.id.toString(), - email: admin.email, - role: admin.role, - signature: admin.signature - }); + const role = isUserAdmin ? (userRecord.role || 'admin') : 'intern'; - return data({ - token, - admin: { - id: admin.id.toString(), - nama_depan: admin.nama_depan, - nama_belakang: admin.nama_belakang, - email: admin.email, - role: admin.role, - signature: admin.signature, - }, - }); + const tokenPayload = { + id: userRecord.id.toString(), + email: userRecord.email, + role: role, + signature: isUserAdmin ? userRecord.signature : null + }; + + const token = createToken(tokenPayload); + + if (isUserAdmin) { + return data({ + token, + user: { + id: userRecord.id.toString(), + full_name: `${userRecord.nama_depan}${userRecord.nama_belakang ? ' ' + userRecord.nama_belakang : ''}`, + email: userRecord.email, + role: role, + signature: userRecord.signature + }, + admin: { + id: userRecord.id.toString(), + nama_depan: userRecord.nama_depan, + nama_belakang: userRecord.nama_belakang, + email: userRecord.email, + role: role, + signature: userRecord.signature, + } + }); + } else { + return data({ + token, + user: { + id: userRecord.id.toString(), + full_name: userRecord.full_name, + email: userRecord.email, + role: role, + } + }); + } } catch (err) { return error(err); } } - async getCurrentUser(userId) { + async getCurrentUser(userId, role) { try { - const result = await adminService.getAdminById(userId); + if (role === 'admin') { + const result = await adminService.getAdminById(userId); - if (result.err) { - return result; + if (result.err) { + return result; + } + + const { password, ...adminWithoutPassword } = result.data; + + return data({ + ...adminWithoutPassword, + role: 'admin' + }); + } else if (role === 'intern') { + const user = await prisma.user.findUnique({ + where: { + id: parseInt(userId), + is_active: true + }, + include: { + lamaran_magang: { + include: { + lowongan_magang: { + select: { + posisi: true, + kelompok_peminatan: true + } + } + } + } + } + }); + + if (!user) { + return error(new NotFoundError('Pengguna tidak ditemukan')); + } + + const { password, ...userWithoutPassword } = user; + + return data(userWithoutPassword); + } else { + return error(new NotFoundError('Role tidak valid')); } - - const { password, ...adminWithoutPassword } = result.data; - - return data(adminWithoutPassword); } catch (err) { return error(new NotFoundError('Pengguna tidak ditemukan')); } @@ -67,6 +142,146 @@ class AuthService { return error(err); } } + + async updateProfile(userId, role, payload, imageFile) { + try { + const { full_name, email, password, professional_bio } = payload; + + if (email) { + const existingAdmin = await prisma.admin.findUnique({ + where: { email } + }); + if (existingAdmin && (role !== 'admin' || existingAdmin.id !== parseInt(userId))) { + if (imageFile?.filename) { + const { deleteFileIfExists } = require('../../../helpers/utils/fileHelper'); + await deleteFileIfExists(imageFile.filename).catch(() => { }); + } + return error(new ConflictError('Email sudah terdaftar')); + } + + const existingUser = await prisma.user.findUnique({ + where: { email } + }); + if (existingUser && (role !== 'intern' || existingUser.id !== parseInt(userId))) { + if (imageFile?.filename) { + const { deleteFileIfExists } = require('../../../helpers/utils/fileHelper'); + await deleteFileIfExists(imageFile.filename).catch(() => { }); + } + return error(new ConflictError('Email sudah terdaftar')); + } + } + + let hashedPassword = undefined; + if (password) { + hashedPassword = await bcrypt.hash(password, 10); + } + + if (role === 'admin') { + const adminId = parseInt(userId); + const existingAdmin = await prisma.admin.findUnique({ + where: { id: adminId } + }); + if (!existingAdmin) { + if (imageFile?.filename) { + const { deleteFileIfExists } = require('../../../helpers/utils/fileHelper'); + await deleteFileIfExists(imageFile.filename).catch(() => { }); + } + return error(new NotFoundError('Admin tidak ditemukan')); + } + + const updateData = {}; + if (full_name) { + const nameParts = full_name.trim().split(/\s+/); + updateData.nama_depan = nameParts[0]; + updateData.nama_belakang = nameParts.slice(1).join(' ') || ''; + } + if (email) updateData.email = email; + if (hashedPassword) updateData.password = hashedPassword; + if (professional_bio !== undefined) updateData.professional_bio = professional_bio; + + if (imageFile) { + const imageUploadHelper = require('../../../helpers/utils/imageUpload.helper'); + const imagePath = imageUploadHelper.uploadImage(imageFile); + updateData.profile_picture = imagePath; + + if (existingAdmin.profile_picture) { + const { deleteFileIfExists } = require('../../../helpers/utils/fileHelper'); + const path = require('path'); + const oldFileName = path.basename(existingAdmin.profile_picture); + await deleteFileIfExists(oldFileName).catch(() => { }); + } + } + + const updatedAdmin = await prisma.admin.update({ + where: { id: adminId }, + data: updateData + }); + + const { password: _, ...adminWithoutPassword } = updatedAdmin; + return data({ + ...adminWithoutPassword, + role: 'admin' + }); + + } else if (role === 'intern') { + if (imageFile) { + const { deleteFileIfExists } = require('../../../helpers/utils/fileHelper'); + await deleteFileIfExists(imageFile.filename).catch(() => { }); + return error(new ForbiddenError('Hanya admin yang dapat mengupload foto profil')); + } + + const internId = parseInt(userId); + const existingUser = await prisma.user.findUnique({ + where: { id: internId } + }); + if (!existingUser) { + return error(new NotFoundError('User tidak ditemukan')); + } + + const updateData = {}; + if (full_name) updateData.full_name = full_name; + if (email) updateData.email = email; + if (hashedPassword) updateData.password = hashedPassword; + if (professional_bio !== undefined) updateData.professional_bio = professional_bio; + + const updatedUser = await prisma.user.update({ + where: { id: internId }, + data: updateData, + include: { + lamaran_magang: { + include: { + lowongan_magang: { + select: { + posisi: true, + kelompok_peminatan: true + } + } + } + } + } + }); + + const { password: _, ...userWithoutPassword } = updatedUser; + return data(userWithoutPassword); + } else { + if (imageFile?.filename) { + const { deleteFileIfExists } = require('../../../helpers/utils/fileHelper'); + await deleteFileIfExists(imageFile.filename).catch(() => { }); + } + return error(new NotFoundError('Role tidak valid')); + } + } catch (err) { + if (imageFile?.filename) { + try { + const { deleteFileIfExists } = require('../../../helpers/utils/fileHelper'); + await deleteFileIfExists(imageFile.filename); + } catch (cleanupErr) { + console.error('Failed to cleanup file:', cleanupErr); + } + } + return error(err); + } + } } module.exports = new AuthService(); diff --git a/src/modules/lamaranMagang/helpers/email.helper.js b/src/modules/lamaranMagang/helpers/email.helper.js index 19ca822..8c4a752 100755 --- a/src/modules/lamaranMagang/helpers/email.helper.js +++ b/src/modules/lamaranMagang/helpers/email.helper.js @@ -43,7 +43,7 @@ const sendConfirmationEmail = async (mahasiswa, lowongan) => { } }; -const sendStatusEmail = async (lamaran, status) => { +const sendStatusEmail = async (lamaran, status, generatedPassword = null) => { try { const { mahasiswa, lowongan_magang } = lamaran; const { nama_depan, nama_belakang, email } = mahasiswa; @@ -61,6 +61,14 @@ const sendStatusEmail = async (lamaran, status) => {
Halo ${fullName},
Selamat! Lamaran Anda untuk posisi ${posisi} di Humic Engineering telah diterima.
Tim kami sangat terkesan dengan profil dan kualifikasi Anda. Kami akan segera menghubungi Anda terkait tahapan selanjutnya.
+ ${generatedPassword ? ` +Akun Portal Intern Anda telah dibuat:
+Email: ${email}
+Password: ${generatedPassword}
+Silakan masuk menggunakan akun ini untuk melihat project dan tugas magang Anda.
+Terima kasih telah melamar dan kami menantikan kerja sama yang luar biasa bersama Anda.
` : ` diff --git a/src/modules/lamaranMagang/services/lamaranMagang.service.js b/src/modules/lamaranMagang/services/lamaranMagang.service.js index 67197a0..56db474 100755 --- a/src/modules/lamaranMagang/services/lamaranMagang.service.js +++ b/src/modules/lamaranMagang/services/lamaranMagang.service.js @@ -2,6 +2,9 @@ const lamaranMagangRepository = require('../repositories/lamaranMagang.repositor 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'); @@ -151,7 +154,40 @@ class LamaranMagangService { const result = await lamaranMagangRepository.updateStatus(id_lamaran_magang, status); - await sendStatusEmail(checkResult.data, 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) { diff --git a/src/modules/project/controllers/project.controller.js b/src/modules/project/controllers/project.controller.js index e3e7c16..40f3a81 100644 --- a/src/modules/project/controllers/project.controller.js +++ b/src/modules/project/controllers/project.controller.js @@ -162,6 +162,111 @@ class ProjectController { ); } } + + async getMyProjects(req, res) { + try { + const actor = getActor(req); + const result = await projectService.getMyProjects(actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Intern projects retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getMyTasks(req, res) { + try { + const actor = getActor(req); + const result = await projectService.getMyTasks(actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Intern tasks retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getMentorProjects(req, res) { + try { + const actor = getActor(req); + const result = await projectService.getMentorProjects(actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Mentor projects retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getInterns(req, res) { + try { + const actor = getActor(req); + const result = await projectService.getInterns(actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Interns retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async assignMember(req, res) { + try { + const actor = getActor(req); + const result = await projectService.assignMember(req.body, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Member assigned 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/repositories/project.repository.js b/src/modules/project/repositories/project.repository.js index 2ed69d3..5b941cf 100644 --- a/src/modules/project/repositories/project.repository.js +++ b/src/modules/project/repositories/project.repository.js @@ -203,6 +203,193 @@ class ProjectRepository { }, }; } + + async findProjectsByIntern(internId) { + return prisma.project.findMany({ + where: { + members: { + some: { + id_user: parseInt(internId), + status: "active", + }, + }, + }, + orderBy: { + created_at: "desc", + }, + include: { + admin: { + select: { + id: true, + nama_depan: true, + nama_belakang: true, + email: true, + profile_picture: true, + professional_bio: true, + }, + }, + members: { + where: { + status: "active", + }, + select: { + id: true, + }, + }, + tasks: { + select: { + id: true, + }, + }, + }, + }); + } + + async findTasksByIntern(internId) { + return prisma.task.findMany({ + where: { + project: { + members: { + some: { + id_user: parseInt(internId), + status: "active", + }, + }, + }, + }, + orderBy: { + deadline_at: "asc", + }, + include: { + project: { + select: { + id: true, + project_name: true, + project_icon: true, + }, + }, + submissions: { + where: { + id_user: parseInt(internId), + }, + select: { + id: true, + file_path: true, + url_link: true, + submitted_at: true, + updated_at: true, + }, + }, + }, + }); + } + + async findProjectsByMentor(adminId) { + return prisma.project.findMany({ + where: { + id_admin: parseInt(adminId), + }, + orderBy: { + created_at: "desc", + }, + include: { + admin: { + select: { + id: true, + nama_depan: true, + nama_belakang: true, + email: true, + profile_picture: true, + professional_bio: true, + }, + }, + members: { + where: { + status: "active", + }, + select: { + id: true, + }, + }, + tasks: { + select: { + id: true, + }, + }, + }, + }); + } + + async findActiveInternsWithProjects() { + return prisma.user.findMany({ + where: { + role: "intern", + is_active: true, + }, + include: { + project_members: { + where: { + status: "active", + }, + include: { + project: { + select: { + id: true, + project_name: true, + }, + }, + }, + }, + lamaran_magang: { + include: { + lowongan_magang: { + select: { + posisi: true, + }, + }, + }, + }, + }, + orderBy: { + id: "asc", + }, + }); + } + + async assignMember(id_project, id_user) { + return prisma.projectMember.create({ + data: { + id_project: parseInt(id_project), + id_user: parseInt(id_user), + status: "active", + }, + }); + } + + async findUserByIdAndActive(id) { + return prisma.user.findFirst({ + where: { + id: parseInt(id), + is_active: true, + }, + }); + } + + async findMembership(idProject, idUser) { + return prisma.projectMember.findFirst({ + where: { + id_project: parseInt(idProject), + id_user: parseInt(idUser), + }, + }); + } + + async updateMembershipStatus(id, status) { + return prisma.projectMember.update({ + where: { id: parseInt(id) }, + data: { status }, + }); + } } module.exports = new ProjectRepository(); diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index 6eca4e3..d1775d1 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -4,6 +4,7 @@ const { BadRequestError, NotFoundError, ForbiddenError, + ConflictError, } = require("../../../helpers/error"); class ProjectService { @@ -305,6 +306,209 @@ class ProjectService { updated_at: member.updated_at, }; } + + async getMyProjects(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 projects"), + ); + } + + const projects = await projectRepository.findProjectsByIntern(actor.id); + + const mappedProjects = projects.map((project) => ({ + id: project.id, + project_icon: project.project_icon, + project_name: project.project_name, + 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, + total_tasks: project.tasks?.length || 0, + created_at: project.created_at, + updated_at: project.updated_at, + })); + + return data(mappedProjects); + } catch (err) { + return error(err); + } + } + + async getMyTasks(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 tasks"), + ); + } + + const tasks = await projectRepository.findTasksByIntern(actor.id); + + const mappedTasks = tasks.map((task) => { + const submission = task.submissions?.[0] || null; + return { + id: task.id, + id_project: task.id_project, + project_name: task.project?.project_name || null, + project_icon: task.project?.project_icon || null, + 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, + submission_status: submission ? "submitted" : "not_submitted", + submission_details: submission ? { + id: submission.id, + file_path: submission.file_path, + url_link: submission.url_link, + submitted_at: submission.submitted_at, + updated_at: submission.updated_at, + } : null, + }; + }); + + return data(mappedTasks); + } catch (err) { + return error(err); + } + } + + async getMentorProjects(actor = {}) { + try { + if (!actor?.id) { + return error(new BadRequestError("Admin ID is required")); + } + + if (actor.role !== "admin") { + return error( + new ForbiddenError("Access denied: only mentors/admins can view their created projects"), + ); + } + + const projects = await projectRepository.findProjectsByMentor(actor.id); + + const mappedProjects = projects.map((project) => ({ + id: project.id, + project_icon: project.project_icon, + project_name: project.project_name, + 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, + total_tasks: project.tasks?.length || 0, + created_at: project.created_at, + updated_at: project.updated_at, + })); + + return data(mappedProjects); + } catch (err) { + return error(err); + } + } + + async getInterns(actor = {}) { + try { + if (!actor?.id) { + return error(new BadRequestError("Admin ID is required")); + } + + if (actor.role !== "admin") { + return error( + new ForbiddenError("Access denied: only admin/mentor can access interns list"), + ); + } + + const interns = await projectRepository.findActiveInternsWithProjects(); + + const mappedInterns = interns.map((intern) => { + 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, + email: intern.email, + projectName: projectName, + role: role, + isAssignedByMentor: projectName !== "Unassigned", + avatar: null + }; + }); + + return data(mappedInterns); + } catch (err) { + return error(err); + } + } + + async assignMember(payload, actor = {}) { + try { + if (!actor?.id) { + return error(new BadRequestError("Admin ID is required")); + } + + if (actor.role !== "admin") { + return error( + new ForbiddenError("Access denied: only mentors/admins can assign members"), + ); + } + + const { id_project, id_user } = payload; + + if (!id_project || !id_user) { + 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; + if (existingMembership) { + 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); + } + + return data(membership); + } catch (err) { + return error(err); + } + } } module.exports = new ProjectService(); diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 6b39d98..f660492 100755 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -1,6 +1,8 @@ const express = require('express'); const { authController } = require('../modules/auth'); const { verifyJWT } = require('../middleware/verifyJWT'); +const multer = require('../middleware/multer'); +const { multerErrorHandler } = require('../middleware/multer'); const router = express.Router(); @@ -92,4 +94,52 @@ router.post("/login", authController.login); */ router.get("/me", verifyJWT, authController.me); +/** + * @swagger + * /auth-api/update-profile: + * patch: + * summary: Update authenticated user profile + * description: Update profile details (full name, email, password, professional bio). Admins can also upload a profile picture. + * tags: [Auth] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * full_name: + * type: string + * example: "Jonathan Kristina" + * email: + * type: string + * example: "jonathan.kristina@example.com" + * password: + * type: string + * example: "newSecurePassword123" + * professional_bio: + * type: string + * example: "Experienced UI/UX Designer and Engineer" + * profile_picture: + * type: string + * format: binary + * description: Admin only. Optional profile picture upload. + * responses: + * 200: + * description: Profile successfully updated + * 400: + * description: Bad Request (Validation error) + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Interns uploading images) + * 409: + * description: Conflict (Email already in use) + * 500: + * description: Internal server error + */ +router.patch('/update-profile', verifyJWT, multer.single('profile_picture'), multerErrorHandler, authController.updateProfile); + module.exports = router; \ No newline at end of file diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 8fbebf5..3bb9224 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -271,4 +271,169 @@ router.patch('/update/:id', verifyJWT, isAdmin, projectController.updateProject) */ router.delete('/delete/:id', verifyJWT, isAdmin, projectController.archiveProject); +/** + * @swagger + * /project-api/my-projects: + * get: + * summary: Get intern's active projects + * description: Retrieve all active projects where the logged-in intern is a member. + * tags: [Project] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Intern projects retrieved successfully + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Only for interns) + * 500: + * description: Internal server error + */ +router.get('/my-projects', verifyJWT, projectController.getMyProjects); + +/** + * @swagger + * /project-api/my-tasks: + * get: + * summary: Get intern's tasks and submission statuses + * description: Retrieve all tasks from all projects where the logged-in intern is an active member, including their specific submission detail/status. + * tags: [Project] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Intern tasks retrieved successfully + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Only for interns) + * 500: + * description: Internal server error + */ +router.get('/my-tasks', verifyJWT, projectController.getMyTasks); + +/** + * @swagger + * /project-api/mentor-projects: + * get: + * summary: Get projects created by mentor + * description: Retrieve all projects created by the logged-in admin/mentor. + * tags: [Project] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Mentor projects retrieved successfully + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Only for mentors) + * 500: + * description: Internal server error + */ +router.get('/mentor-projects', verifyJWT, isAdmin, projectController.getMentorProjects); + +/** + * @swagger + * /project-api/interns: + * get: + * summary: Get all interns details + * description: Retrieve details for all active interns, including their current assigned project and project role. Accessible only by admin/mentor. + * tags: [Project] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Interns details retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * message: + * type: string + * example: "Interns retrieved successfully" + * data: + * type: array + * items: + * 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" + * role: + * type: string + * example: "Web Developer" + * isAssignedByMentor: + * type: boolean + * example: true + * avatar: + * type: string + * nullable: true + * example: null + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Only for admin/mentors) + * 500: + * description: Internal server error + */ +router.get('/interns', verifyJWT, isAdmin, projectController.getInterns); + +/** + * @swagger + * /project-api/assign-member: + * post: + * summary: Assign an intern to a project + * description: Assigns an intern to 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 assigned successfully + * 400: + * description: Bad request + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Only for admin/mentors) + * 404: + * description: Project or User not found + * 409: + * description: Conflict (User is already an active member of this project) + * 500: + * description: Internal server error + */ +router.post('/assign-member', verifyJWT, isAdmin, projectController.assignMember); + module.exports = router;