From 9bdb781f340fb0f86f811c9078c6406e0d5cbb35 Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Mon, 8 Jun 2026 04:38:51 +0700 Subject: [PATCH 1/3] feat: menambah dan memperbarui skema database untuk project, project member, dan task lms internify --- .../migration.sql | 116 +++++++++++++++ prisma/schema.prisma | 135 ++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 prisma/migrations/20260607213625_lms_scheme_preparation/migration.sql diff --git a/prisma/migrations/20260607213625_lms_scheme_preparation/migration.sql b/prisma/migrations/20260607213625_lms_scheme_preparation/migration.sql new file mode 100644 index 0000000..f837704 --- /dev/null +++ b/prisma/migrations/20260607213625_lms_scheme_preparation/migration.sql @@ -0,0 +1,116 @@ +-- AlterTable +ALTER TABLE `admin` ADD COLUMN `professional_bio` TEXT NULL, + ADD COLUMN `profile_picture` TEXT NULL, + MODIFY `role` ENUM('admin', 'intern') NOT NULL DEFAULT 'admin'; + +-- CreateTable +CREATE TABLE `user` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `id_mahasiswa` INTEGER NOT NULL, + `id_lamaran_magang` INTEGER NOT NULL, + `full_name` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NOT NULL, + `password` TEXT NOT NULL, + `role` ENUM('admin', 'intern') NOT NULL DEFAULT 'intern', + `professional_bio` TEXT NULL, + `is_active` BOOLEAN NOT NULL DEFAULT true, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `user_id_mahasiswa_key`(`id_mahasiswa`), + UNIQUE INDEX `user_id_lamaran_magang_key`(`id_lamaran_magang`), + UNIQUE INDEX `user_email_key`(`email`), + INDEX `user_email_idx`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `project` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `id_admin` INTEGER NOT NULL, + `project_icon` ENUM('code', 'chart', 'cloud', 'mobile', 'gear', 'users', 'clipboard', 'speedometer', 'lightbulb', 'shield') NOT NULL DEFAULT 'code', + `project_name` VARCHAR(255) NOT NULL, + `description` TEXT NOT NULL, + `start_date` DATE NOT NULL, + `end_date` DATE NOT NULL, + `max_members` INTEGER NOT NULL DEFAULT 8, + `status` ENUM('active', 'completed', 'archived') NOT NULL DEFAULT 'active', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `project_id_admin_foreign`(`id_admin`), + INDEX `project_status_idx`(`status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `project_member` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `id_project` INTEGER NOT NULL, + `id_user` INTEGER NOT NULL, + `status` ENUM('active', 'removed') NOT NULL DEFAULT 'active', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `project_member_id_project_foreign`(`id_project`), + INDEX `project_member_id_user_foreign`(`id_user`), + INDEX `project_member_status_idx`(`status`), + UNIQUE INDEX `project_member_id_project_id_user_key`(`id_project`, `id_user`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `task` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `id_project` INTEGER NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT NOT NULL, + `deadline_at` DATETIME(3) NOT NULL, + `submission_type` ENUM('file_upload', 'url_link') NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `task_id_project_foreign`(`id_project`), + INDEX `task_deadline_at_idx`(`deadline_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `task_submission` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `id_task` INTEGER NOT NULL, + `id_user` INTEGER NOT NULL, + `file_path` TEXT NULL, + `url_link` TEXT NULL, + `submitted_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `task_submission_id_task_foreign`(`id_task`), + INDEX `task_submission_id_user_foreign`(`id_user`), + UNIQUE INDEX `task_submission_id_task_id_user_key`(`id_task`, `id_user`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `user` ADD CONSTRAINT `user_id_mahasiswa_foreign` FOREIGN KEY (`id_mahasiswa`) REFERENCES `mahasiswa`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user` ADD CONSTRAINT `user_id_lamaran_magang_foreign` FOREIGN KEY (`id_lamaran_magang`) REFERENCES `lamaran_magang`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `project` ADD CONSTRAINT `project_id_admin_foreign` FOREIGN KEY (`id_admin`) REFERENCES `admin`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `project_member` ADD CONSTRAINT `project_member_id_project_foreign` FOREIGN KEY (`id_project`) REFERENCES `project`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `project_member` ADD CONSTRAINT `project_member_id_user_foreign` FOREIGN KEY (`id_user`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `task` ADD CONSTRAINT `task_id_project_foreign` FOREIGN KEY (`id_project`) REFERENCES `project`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `task_submission` ADD CONSTRAINT `task_submission_id_task_foreign` FOREIGN KEY (`id_task`) REFERENCES `task`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `task_submission` ADD CONSTRAINT `task_submission_id_user_foreign` FOREIGN KEY (`id_user`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1ee42f3..e226758 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,11 @@ model Admin { password String @db.Text signature String? @db.Text // singature untuk identifikasi auth jwt (best practice) + profile_picture String? @db.Text + professional_bio String? @db.Text + + projects Project[] + @@map("admin") } @@ -47,6 +52,7 @@ model LamaranMagang { created_at DateTime @default(now()) update_at DateTime + user User? batch_data Batch? @relation(fields: [batch], references: [id], map: "lamaran_magang_batch_foreign") mahasiswa Mahasiswa @relation(fields: [id_mahasiswa], references: [id], map: "lamaran_magang_id_mahasiswa_foreign") lowongan_magang LowonganMagang @relation(fields: [id_lowongan_magang], references: [id], map: "lamaran_magang_id_lowongan_magang_foreign") @@ -95,6 +101,7 @@ model Mahasiswa { motivasi String @db.Text relevant_skills String @db.VarChar(255) + user User? lamaran_magang LamaranMagang[] @@map("mahasiswa") @@ -170,8 +177,107 @@ model Batch { @@map("batch") } +model User { + id Int @id @default(autoincrement()) + id_mahasiswa Int @unique + id_lamaran_magang Int @unique + full_name String @db.VarChar(255) + email String @unique @db.VarChar(255) + password String @db.Text + role Role @default(intern) + professional_bio String? @db.Text + is_active Boolean @default(true) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + mahasiswa Mahasiswa @relation(fields: [id_mahasiswa], references: [id], map: "user_id_mahasiswa_foreign") + lamaran_magang LamaranMagang @relation(fields: [id_lamaran_magang], references: [id], map: "user_id_lamaran_magang_foreign") + project_members ProjectMember[] + task_submissions TaskSubmission[] + + @@index([email]) + @@map("user") +} + +model Project { + id Int @id @default(autoincrement()) + id_admin Int //mentor project + project_icon ProjectIcon @default(code) + project_name String @db.VarChar(255) + 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 + + admin Admin @relation(fields: [id_admin], references: [id], map: "project_id_admin_foreign") + members ProjectMember[] + tasks Task[] + + @@index([id_admin], map: "project_id_admin_foreign") + @@index([status]) + @@map("project") +} + +model ProjectMember { + id Int @id @default(autoincrement()) + id_project Int + id_user Int + status ProjectMemberStatus @default(active) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + project Project @relation(fields: [id_project], references: [id], map: "project_member_id_project_foreign") + user User @relation(fields: [id_user], references: [id], map: "project_member_id_user_foreign") + + @@unique([id_project, id_user]) + @@index([id_project], map: "project_member_id_project_foreign") + @@index([id_user], map: "project_member_id_user_foreign") + @@index([status]) + @@map("project_member") +} + +model Task { + id Int @id @default(autoincrement()) + id_project Int + title String @db.VarChar(255) + description String @db.Text + deadline_at DateTime + submission_type SubmissionType + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + project Project @relation(fields: [id_project], references: [id], map: "task_id_project_foreign") + submissions TaskSubmission[] + + @@index([id_project], map: "task_id_project_foreign") + @@index([deadline_at]) + @@map("task") +} + +model TaskSubmission { + id Int @id @default(autoincrement()) + id_task Int + id_user Int + file_path String? @db.Text + url_link String? @db.Text + submitted_at DateTime @default(now()) + updated_at DateTime @updatedAt + + task Task @relation(fields: [id_task], references: [id], map: "task_submission_id_task_foreign") + user User @relation(fields: [id_user], references: [id], map: "task_submission_id_user_foreign") + + @@unique([id_task, id_user]) + @@index([id_task], map: "task_submission_id_task_foreign") + @@index([id_user], map: "task_submission_id_user_foreign") + @@map("task_submission") +} + enum Role { admin + intern } enum StudentRole { @@ -193,3 +299,32 @@ enum PaidStatus { paid unpaid } + +enum ProjectIcon { + code + chart + cloud + mobile + gear + users + clipboard + speedometer + lightbulb + shield +} + +enum ProjectStatus { + active + completed + archived +} + +enum ProjectMemberStatus { + active + removed +} + +enum SubmissionType { + file_upload + url_link +} From f0df564499dfa9ef49867783fdcfd0a79f0bf482 Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Mon, 8 Jun 2026 05:44:53 +0700 Subject: [PATCH 2/3] feat: CRUD untuk feature project intern --- src/docs/swagger.js | 4 + src/index.js | 161 ++++----- .../project/controllers/project.controller.js | 167 ++++++++++ src/modules/project/index.js | 9 + src/modules/project/models/project.model.js | 101 ++++++ .../repositories/project.repository.js | 208 ++++++++++++ .../project/services/project.service.js | 310 ++++++++++++++++++ src/modules/projectMember/index.js | 0 src/modules/task/index.js | 0 src/routes/project.routes.js | 274 ++++++++++++++++ src/routes/projectMember.routes.js | 0 src/routes/task.routes.js | 0 12 files changed, 1156 insertions(+), 78 deletions(-) create mode 100644 src/modules/project/controllers/project.controller.js create mode 100644 src/modules/project/index.js create mode 100644 src/modules/project/models/project.model.js create mode 100644 src/modules/project/repositories/project.repository.js create mode 100644 src/modules/project/services/project.service.js create mode 100644 src/modules/projectMember/index.js create mode 100644 src/modules/task/index.js create mode 100644 src/routes/project.routes.js create mode 100644 src/routes/projectMember.routes.js create mode 100644 src/routes/task.routes.js diff --git a/src/docs/swagger.js b/src/docs/swagger.js index e57c04a..dc35ebc 100755 --- a/src/docs/swagger.js +++ b/src/docs/swagger.js @@ -63,6 +63,10 @@ const swaggerOptions = { { name: "Feedback", description: "Internship feedback and testimonial management endpoints" + }, + { + name: "Project", + description: "Internify LMS project management endpoints" } ], components: { diff --git a/src/index.js b/src/index.js index 510536b..7831774 100755 --- a/src/index.js +++ b/src/index.js @@ -1,78 +1,83 @@ -require('dotenv').config(); -const express = require('express'); -const cors = require('cors'); -const path = require('path'); -const adminRoutes = require('./routes/admin.routes'); -const authRoutes = require('./routes/auth.routes'); -const lowonganMagangRoutes = require('./routes/lowonganMagang.routes'); -const lamaranMagangRoutes = require('./routes/lamaranMagang.routes'); -const mahasiswaRoutes = require('./routes/mahasiswa.routes'); -const partnershipRoutes = require('./routes/partnership.routes'); -const hasilResearchRoutes = require('./routes/hasilResearch.routes'); -const faqRoutes = require('./routes/faq.routes'); -const feedbackRoutes = require('./routes/feedback.routes'); -const batchRoutes = require('./routes/batch.routes'); -const setupSwaggerDocs = require('./docs/swagger'); - -const PORT = process.env.PORT; -const app = express(); - -const corsOptions = { - origin: true, - methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], - credentials: true, - allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With", "Accept"], - exposedHeaders: ["Content-Range", "X-Content-Range"], - optionsSuccessStatus: 200, -}; - -const uploadDir = process.env.NODE_ENV === 'production' - ? '/tmp/uploads' - : path.join(__dirname, 'uploads'); - -app.options('*', cors(corsOptions)); - -app.use(express.json()); -app.use(cors(corsOptions)); - -app.use("/uploads", express.static(uploadDir), (req, res, next) => { - if (!res.headersSent) { - res.status(404).json({ - status: false, - message: `File not found: ${req.path}`, - code: 404 - }); - } -}); -app.use("/admin-api", adminRoutes); -app.use("/auth-api", authRoutes); -app.use("/lowongan-magang-api", lowonganMagangRoutes); -app.use("/lamaran-magang-api", lamaranMagangRoutes); -app.use("/mahasiswa-api", mahasiswaRoutes); -app.use("/partnership-api", partnershipRoutes); -app.use("/hasil-research-api", hasilResearchRoutes); -app.use("/faq-api", faqRoutes); -app.use("/feedback-api", feedbackRoutes); -app.use("/batch-api", batchRoutes); - -setupSwaggerDocs(app); - -app.use((req, res) => { - res.status(404).json({ - status: false, - message: `Route not found: ${req.method} ${req.path}`, - code: 404 - }); -}); - -if (process.env.NODE_ENV !== 'production') { - app.listen(PORT, () => { - console.log(`listening at http://localhost:${PORT}`); - }); -} - -module.exports = app; - -/* - NOTE : saran saya untuk penamaan variabel untuk setiap response dibuat konsisten menjadi inggris, tolong komunikasi aja ke fe 😁 -*/ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const path = require('path'); +const adminRoutes = require('./routes/admin.routes'); +const authRoutes = require('./routes/auth.routes'); +const lowonganMagangRoutes = require('./routes/lowonganMagang.routes'); +const lamaranMagangRoutes = require('./routes/lamaranMagang.routes'); +const mahasiswaRoutes = require('./routes/mahasiswa.routes'); +const partnershipRoutes = require('./routes/partnership.routes'); +const hasilResearchRoutes = require('./routes/hasilResearch.routes'); +const faqRoutes = require('./routes/faq.routes'); +const feedbackRoutes = require('./routes/feedback.routes'); +const batchRoutes = require('./routes/batch.routes'); +const projectRoutes = require('./routes/project.routes'); +const setupSwaggerDocs = require('./docs/swagger'); + +const PORT = process.env.PORT; +const app = express(); + +const corsOptions = { + origin: true, + methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], + credentials: true, + allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With", "Accept"], + exposedHeaders: ["Content-Range", "X-Content-Range"], + optionsSuccessStatus: 200, +}; + +const uploadDir = process.env.NODE_ENV === 'production' + ? '/tmp/uploads' + : path.join(__dirname, 'uploads'); + +app.options('*', cors(corsOptions)); + +app.use(express.json()); +app.use(cors(corsOptions)); + +app.use("/uploads", express.static(uploadDir), (req, res, next) => { + if (!res.headersSent) { + res.status(404).json({ + status: false, + message: `File not found: ${req.path}`, + code: 404 + }); + } +}); +app.use("/admin-api", adminRoutes); +app.use("/auth-api", authRoutes); +app.use("/lowongan-magang-api", lowonganMagangRoutes); +app.use("/lamaran-magang-api", lamaranMagangRoutes); +app.use("/mahasiswa-api", mahasiswaRoutes); +app.use("/partnership-api", partnershipRoutes); +app.use("/hasil-research-api", hasilResearchRoutes); +app.use("/faq-api", faqRoutes); +app.use("/feedback-api", feedbackRoutes); +app.use("/batch-api", batchRoutes); +app.use("/project-api", projectRoutes); +// app.use("/project-member-api", projectMemberRoutes); +// app.use("/task-api", taskRoutes); + + +setupSwaggerDocs(app); + +app.use((req, res) => { + res.status(404).json({ + status: false, + message: `Route not found: ${req.method} ${req.path}`, + code: 404 + }); +}); + +if (process.env.NODE_ENV !== 'production') { + app.listen(PORT, () => { + console.log(`listening at http://localhost:${PORT}`); + }); +} + +module.exports = app; + +/* + NOTE : saran saya untuk penamaan variabel untuk setiap response dibuat konsisten menjadi inggris, tolong komunikasi aja ke fe 😁 +*/ diff --git a/src/modules/project/controllers/project.controller.js b/src/modules/project/controllers/project.controller.js new file mode 100644 index 0000000..e3e7c16 --- /dev/null +++ b/src/modules/project/controllers/project.controller.js @@ -0,0 +1,167 @@ +const projectService = require('../services/project.service'); +const { response, error } = require('../../../helpers/utils/wrapper'); +const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code'); +const { InternalServerError } = require('../../../helpers/error'); +const { isValidPayload } = require('../../../helpers/utils/validator'); +const { + createProjectModel, + updateProjectModel, + getAllProjectsModel, +} = require('../models/project.model'); + +const getActor = (req) => ({ + id: req.id, + role: req.role, + email: req.email, +}); + +class ProjectController { + async createProject(req, res) { + try { + const validatePayload = isValidPayload(req.body, createProjectModel); + + if (validatePayload.err) { + return response( + res, + 'fail', + { err: validatePayload.err, data: null }, + 'Invalid project data', + ERROR.EXPECTATION_FAILED + ); + } + + const actor = getActor(req); + const result = await projectService.createProject(validatePayload.data, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Project created successfully', SUCCESS.CREATED); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getAllProjects(req, res) { + try { + const validatePayload = isValidPayload(req.query, getAllProjectsModel); + + if (validatePayload.err) { + return response( + res, + 'fail', + { err: validatePayload.err, data: null }, + 'Invalid query data', + ERROR.EXPECTATION_FAILED + ); + } + + const actor = getActor(req); + const result = await projectService.getAllProjects(validatePayload.data, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Projects retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async getProjectById(req, res) { + try { + const { id } = req.params; + const actor = getActor(req); + + const result = await projectService.getProjectById(id, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Project detail retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async updateProject(req, res) { + try { + const { id } = req.params; + + const validatePayload = isValidPayload(req.body, updateProjectModel); + + if (validatePayload.err) { + return response( + res, + 'fail', + { err: validatePayload.err, data: null }, + 'Invalid project data', + ERROR.EXPECTATION_FAILED + ); + } + + const actor = getActor(req); + const result = await projectService.updateProject(id, validatePayload.data, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Project updated successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async archiveProject(req, res) { + try { + const { id } = req.params; + const actor = getActor(req); + + const result = await projectService.archiveProject(id, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Project archived 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/index.js b/src/modules/project/index.js new file mode 100644 index 0000000..53b3db7 --- /dev/null +++ b/src/modules/project/index.js @@ -0,0 +1,9 @@ +const projectController = require("./controllers/project.controller"); +const projectRepository = require("./repositories/project.repository"); +const projectService = require("./services/project.service"); + +module.exports = { + projectController, + projectService, + projectRepository +} diff --git a/src/modules/project/models/project.model.js b/src/modules/project/models/project.model.js new file mode 100644 index 0000000..8eb9c40 --- /dev/null +++ b/src/modules/project/models/project.model.js @@ -0,0 +1,101 @@ +const joi = require('joi'); + +const PROJECT_ICON_VALUES = [ + 'code', + 'chart', + 'cloud', + 'mobile', + 'gear', + 'users', + 'clipboard', + 'speedometer', + 'lightbulb', + 'shield', +]; + +const PROJECT_STATUS_VALUES = [ + 'active', + 'completed', + 'archived', +]; + +const createProjectModel = joi.object().keys({ + project_icon: joi.string().valid(...PROJECT_ICON_VALUES).required().messages({ + 'string.base': 'Project icon must be a string.', + 'string.empty': 'Project icon cannot be empty.', + 'any.only': `Project icon must be one of: ${PROJECT_ICON_VALUES.join(', ')}.`, + 'any.required': 'Project icon is required.', + }), + + project_name: joi.string().max(255).required().messages({ + 'string.base': 'Project name must be a string.', + 'string.empty': 'Project name cannot be empty.', + 'string.max': 'Project name must not exceed {#limit} characters.', + 'any.required': 'Project name is required.', + }), + + description: joi.string().optional().allow('', null).messages({ + 'string.base': 'Project description must be a string.', + }), + + start_date: joi.date().required().messages({ + 'date.base': 'Start date must be a valid date.', + 'any.required': 'Start date is required.', + }), + + end_date: joi.date().min(joi.ref('start_date')).required().messages({ + 'date.base': 'End date must be a valid date.', + 'date.min': 'End date must be greater than or equal to start date.', + 'any.required': 'End date is required.', + }), + + member_emails: joi.array().items( + joi.string().email({ tlds: { allow: false } }).messages({ + 'string.email': 'Member email format is invalid.', + }) + ).optional().default([]).messages({ + 'array.base': 'Member emails must be an array.', + }), +}); + +const updateProjectModel = joi.object().keys({ + project_icon: joi.string().valid(...PROJECT_ICON_VALUES).optional().messages({ + 'string.base': 'Project icon must be a string.', + 'any.only': `Project icon must be one of: ${PROJECT_ICON_VALUES.join(', ')}.`, + }), + + project_name: joi.string().max(255).optional().messages({ + 'string.base': 'Project name must be a string.', + 'string.max': 'Project name must not exceed {#limit} characters.', + }), + + description: joi.string().optional().allow('', null).messages({ + 'string.base': 'Project description must be a string.', + }), + + start_date: joi.date().optional().messages({ + 'date.base': 'Start date must be a valid date.', + }), + + end_date: joi.date().optional().messages({ + 'date.base': 'End date must be a valid date.', + }), + + status: joi.string().valid(...PROJECT_STATUS_VALUES).optional().messages({ + 'string.base': 'Project status must be a string.', + 'any.only': `Project status must be one of: ${PROJECT_STATUS_VALUES.join(', ')}.`, + }), +}); + +const getAllProjectsModel = joi.object().keys({ + status: joi.string().valid(...PROJECT_STATUS_VALUES).optional().messages({ + 'string.base': 'Project status must be a string.', + 'any.only': `Project status must be one of: ${PROJECT_STATUS_VALUES.join(', ')}.`, + }), +}); + +module.exports = { + createProjectModel, + updateProjectModel, + getAllProjectsModel, +}; diff --git a/src/modules/project/repositories/project.repository.js b/src/modules/project/repositories/project.repository.js new file mode 100644 index 0000000..2ed69d3 --- /dev/null +++ b/src/modules/project/repositories/project.repository.js @@ -0,0 +1,208 @@ +const prisma = require("../../../helpers/db/db_connection"); + +class ProjectRepository { + async findAdminById(id) { + return prisma.admin.findUnique({ + where: { + id: parseInt(id), + }, + }); + } + + async findUsersByEmails(emails) { + return prisma.user.findMany({ + where: { + email: { + in: emails, + }, + is_active: true, + }, + include: { + lamaran_magang: { + include: { + lowongan_magang: { + select: { + posisi: true, + kelompok_peminatan: true, + }, + }, + }, + }, + }, + }); + } + + async createWithMembers(projectData, userIds = []) { + return prisma.$transaction(async (tx) => { + const project = await tx.project.create({ + data: projectData, + }); + + if (userIds.length > 0) { + await tx.projectMember.createMany({ + data: userIds.map((id_user) => ({ + id_project: project.id, + id_user, + status: "active", + })), + skipDuplicates: true, + }); + } + + return tx.project.findUnique({ + where: { + id: project.id, + }, + include: this.projectDetailInclude(), + }); + }); + } + + async findAll({ status, actor }) { + const where = {}; + + if (status) { + where.status = status; + } + + if (actor?.role === "intern") { + where.members = { + some: { + id_user: parseInt(actor.id), + status: "active", + }, + }; + } + + return prisma.project.findMany({ + where, + 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 findById(id) { + return prisma.project.findUnique({ + where: { + id: parseInt(id), + }, + include: this.projectDetailInclude(), + }); + } + + async updateById(id, projectData) { + return prisma.project.update({ + where: { + id: parseInt(id), + }, + data: projectData, + include: this.projectDetailInclude(), + }); + } + + async archiveById(id) { + return prisma.project.update({ + where: { + id: parseInt(id), + }, + data: { + status: "archived", + }, + include: this.projectDetailInclude(), + }); + } + + async checkActiveMember(idProject, idUser) { + return prisma.projectMember.findFirst({ + where: { + id_project: parseInt(idProject), + id_user: parseInt(idUser), + status: "active", + }, + }); + } + + projectDetailInclude() { + return { + admin: { + select: { + id: true, + nama_depan: true, + nama_belakang: true, + email: true, + profile_picture: true, + professional_bio: true, + }, + }, + members: { + where: { + status: "active", + }, + include: { + user: { + select: { + id: true, + full_name: true, + email: true, + professional_bio: true, + is_active: true, + lamaran_magang: { + select: { + id: true, + lowongan_magang: { + select: { + posisi: true, + kelompok_peminatan: true, + }, + }, + }, + }, + }, + }, + }, + }, + tasks: { + select: { + id: true, + title: true, + description: true, + deadline_at: true, + submission_type: true, + created_at: true, + updated_at: true, + }, + orderBy: { + deadline_at: "asc", + }, + }, + }; + } +} + +module.exports = new ProjectRepository(); diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js new file mode 100644 index 0000000..6eca4e3 --- /dev/null +++ b/src/modules/project/services/project.service.js @@ -0,0 +1,310 @@ +const projectRepository = require("../repositories/project.repository"); +const { data, error } = require("../../../helpers/utils/wrapper"); +const { + BadRequestError, + NotFoundError, + ForbiddenError, +} = require("../../../helpers/error"); + +class ProjectService { + async createProject(projectPayload, 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 can create project"), + ); + } + + const { member_emails, ...projectData } = projectPayload; + + const admin = await projectRepository.findAdminById(actor.id); + + if (!admin) { + return error(new NotFoundError("Admin not found")); + } + + const normalizedEmails = this.normalizeEmails(member_emails || []); + + if (normalizedEmails.length > 8) { + return error( + new BadRequestError( + "Project member limit exceeded. Maximum member is 8", + ), + ); + } + + const users = + normalizedEmails.length > 0 + ? await projectRepository.findUsersByEmails(normalizedEmails) + : []; + + if (normalizedEmails.length > 0) { + const foundEmails = users.map((user) => user.email.toLowerCase()); + const missingEmails = normalizedEmails.filter( + (email) => !foundEmails.includes(email), + ); + + if (missingEmails.length > 0) { + return error( + new NotFoundError( + `User with these emails not found: ${missingEmails.join(", ")}`, + ), + ); + } + } + + const newProjectData = { + id_admin: parseInt(actor.id), + project_icon: projectData.project_icon, + project_name: projectData.project_name, + description: projectData.description || "", + start_date: projectData.start_date, + end_date: projectData.end_date, + max_members: 8, + status: "active", + }; + + const userIds = users.map((user) => user.id); + const project = await projectRepository.createWithMembers( + newProjectData, + userIds, + ); + + return data(this.mapProjectDetail(project)); + } catch (err) { + return error(err); + } + } + + async getAllProjects(query = {}, actor = {}) { + try { + const projects = await projectRepository.findAll({ + status: query.status, + actor, + }); + + 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 getProjectById(id, actor = {}) { + try { + const project = await projectRepository.findById(id); + + if (!project) { + return error(new NotFoundError("Project not found")); + } + + const accessError = await this.validateProjectAccess(project.id, actor); + if (accessError) { + return error(accessError); + } + + return data(this.mapProjectDetail(project)); + } catch (err) { + return error(err); + } + } + + async updateProject(id, updatePayload, 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 can update project"), + ); + } + + const existingProject = await projectRepository.findById(id); + + if (!existingProject) { + return error(new NotFoundError("Project not found")); + } + + if (Object.keys(updatePayload).length === 0) { + return error( + new BadRequestError( + "At least one field is required to update project", + ), + ); + } + + const updateData = { ...updatePayload }; + + if (updateData.start_date || updateData.end_date) { + const startDate = updateData.start_date || existingProject.start_date; + const endDate = updateData.end_date || existingProject.end_date; + + if (new Date(endDate) < new Date(startDate)) { + return error( + new BadRequestError( + "End date must be greater than or equal to start date", + ), + ); + } + } + + if (updateData.description === null) { + updateData.description = ""; + } + + const updatedProject = await projectRepository.updateById(id, updateData); + + return data(this.mapProjectDetail(updatedProject)); + } catch (err) { + return error(err); + } + } + + async archiveProject(id, 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 can archive project"), + ); + } + + const existingProject = await projectRepository.findById(id); + + if (!existingProject) { + return error(new NotFoundError("Project not found")); + } + + if (existingProject.status === "archived") { + return data(this.mapProjectDetail(existingProject)); + } + + const archivedProject = await projectRepository.archiveById(id); + + return data(this.mapProjectDetail(archivedProject)); + } catch (err) { + return error(err); + } + } + + async validateProjectAccess(idProject, actor = {}) { + if (!actor?.id || !actor?.role) { + return new ForbiddenError( + "Access denied: authentication data is missing", + ); + } + + if (actor.role === "admin") { + return null; + } + + if (actor.role === "intern") { + const membership = await projectRepository.checkActiveMember( + idProject, + actor.id, + ); + + if (!membership) { + return new ForbiddenError( + "Access denied: you are not a member of this project", + ); + } + + return null; + } + + return new ForbiddenError("Access denied: invalid role"); + } + + normalizeEmails(emails = []) { + return [ + ...new Set( + emails.filter(Boolean).map((email) => email.trim().toLowerCase()), + ), + ]; + } + + mapProjectDetail(project) { + if (!project) return null; + + return { + 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), + members: + project.members?.map((member) => this.mapProjectMember(member)) || [], + tasks: project.tasks || [], + total_members: project.members?.length || 0, + total_tasks: project.tasks?.length || 0, + created_at: project.created_at, + updated_at: project.updated_at, + }; + } + + mapAdmin(admin) { + if (!admin) return null; + + return { + id: admin.id, + full_name: `${admin.nama_depan}${admin.nama_belakang ? ` ${admin.nama_belakang}` : ""}`, + email: admin.email, + profile_picture: admin.profile_picture || null, + professional_bio: admin.professional_bio || null, + }; + } + + mapProjectMember(member) { + if (!member) return null; + + const user = member.user; + const lowongan = user?.lamaran_magang?.lowongan_magang; + + return { + id: member.id, + id_user: member.id_user, + status: member.status, + user: { + id: user.id, + full_name: user.full_name, + email: user.email, + professional_bio: user.professional_bio || null, + position: lowongan?.posisi || null, + kelompok_peminatan: lowongan?.kelompok_peminatan || null, + }, + created_at: member.created_at, + updated_at: member.updated_at, + }; + } +} + +module.exports = new ProjectService(); diff --git a/src/modules/projectMember/index.js b/src/modules/projectMember/index.js new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/task/index.js b/src/modules/task/index.js new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js new file mode 100644 index 0000000..8fbebf5 --- /dev/null +++ b/src/routes/project.routes.js @@ -0,0 +1,274 @@ +const express = require('express'); +const { projectController } = require('../modules/project'); +const { verifyJWT } = require('../middleware/verifyJWT'); +const isAdmin = require('../middleware/isAdmin'); + +const router = express.Router(); + +/** + * @swagger + * components: + * schemas: + * Project: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * project_icon: + * type: string + * enum: [code, chart, cloud, mobile, gear, users, clipboard, speedometer, lightbulb, shield] + * example: code + * project_name: + * type: string + * example: Internify LMS + * description: + * type: string + * example: Project for developing Internify LMS features + * start_date: + * type: string + * format: date + * example: 2026-06-10 + * end_date: + * type: string + * format: date + * example: 2026-08-10 + * max_members: + * type: integer + * example: 8 + * status: + * type: string + * enum: [active, completed, archived] + * example: active + * total_members: + * type: integer + * example: 5 + * total_tasks: + * type: integer + * example: 3 + * ProjectCreateRequest: + * type: object + * required: + * - project_icon + * - project_name + * - start_date + * - end_date + * properties: + * project_icon: + * type: string + * enum: [code, chart, cloud, mobile, gear, users, clipboard, speedometer, lightbulb, shield] + * example: code + * project_name: + * type: string + * example: Internify LMS + * description: + * type: string + * example: Project for developing Internify LMS features + * start_date: + * type: string + * format: date + * example: 2026-06-10 + * end_date: + * type: string + * format: date + * example: 2026-08-10 + * member_emails: + * type: array + * items: + * type: string + * format: email + * example: ["intern1@example.com", "intern2@example.com"] + * ProjectUpdateRequest: + * type: object + * properties: + * project_icon: + * type: string + * enum: [code, chart, cloud, mobile, gear, users, clipboard, speedometer, lightbulb, shield] + * example: cloud + * project_name: + * type: string + * example: Internify LMS Updated + * description: + * type: string + * example: Updated project description + * start_date: + * type: string + * format: date + * example: 2026-06-15 + * end_date: + * type: string + * format: date + * example: 2026-08-20 + * status: + * type: string + * enum: [active, completed, archived] + * example: completed + */ + +/** + * @swagger + * /project-api/add: + * post: + * summary: Create a new project + * description: Create a new Internify LMS project. Admin can optionally invite interns by email. + * tags: [Project] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProjectCreateRequest' + * responses: + * 201: + * description: Project created successfully + * 400: + * description: Bad request + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Admin or invited user not found + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.post('/add', verifyJWT, isAdmin, projectController.createProject); + +/** + * @swagger + * /project-api/get: + * get: + * summary: Get all projects + * description: Admin can see all projects. Intern can only see projects where they are active members. + * tags: [Project] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: status + * schema: + * type: string + * enum: [active, completed, archived] + * required: false + * description: Filter projects by status + * example: active + * responses: + * 200: + * description: Projects retrieved successfully + * 401: + * description: Unauthorized + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.get('/get', verifyJWT, projectController.getAllProjects); + +/** + * @swagger + * /project-api/get/{id}: + * get: + * summary: Get project detail + * description: Admin can access any project. Intern can only access project where they are active members. + * tags: [Project] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Project ID + * example: 1 + * responses: + * 200: + * description: Project detail retrieved successfully + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Project not found + * 500: + * description: Internal server error + */ +router.get('/get/:id', verifyJWT, projectController.getProjectById); + +/** + * @swagger + * /project-api/update/{id}: + * patch: + * summary: Update project + * description: Update project information. Only admin can access this endpoint. + * tags: [Project] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Project ID + * example: 1 + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProjectUpdateRequest' + * responses: + * 200: + * description: Project updated successfully + * 400: + * description: Bad request + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Project not found + * 417: + * description: Validation error + * 500: + * description: Internal server error + */ +router.patch('/update/:id', verifyJWT, isAdmin, projectController.updateProject); + +/** + * @swagger + * /project-api/delete/{id}: + * delete: + * summary: Archive project + * description: Archive project by changing its status to archived. This endpoint does not hard delete the project. + * tags: [Project] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Project ID + * example: 1 + * responses: + * 200: + * description: Project archived successfully + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Project not found + * 500: + * description: Internal server error + */ +router.delete('/delete/:id', verifyJWT, isAdmin, projectController.archiveProject); + +module.exports = router; diff --git a/src/routes/projectMember.routes.js b/src/routes/projectMember.routes.js new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/task.routes.js b/src/routes/task.routes.js new file mode 100644 index 0000000..e69de29 From edc287968d92d17dbeaa5fc9c11abbe08bc34746 Mon Sep 17 00:00:00 2001 From: Rafi Athallah <65345768+rafiathallah3@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:28:37 +0700 Subject: [PATCH 3/3] feat: nambahin API assign member, list interns, update profile, serta list projects/tasks --- package-lock.json | 1 + package.json | 3 + prisma/seed.js | 222 ++++++++++++++ src/middleware/isAdmin.js | 2 +- .../auth/controllers/auth.controller.js | 48 ++- src/modules/auth/models/auth.model.js | 19 ++ src/modules/auth/services/auth.service.js | 277 ++++++++++++++++-- .../lamaranMagang/helpers/email.helper.js | 10 +- .../services/lamaranMagang.service.js | 38 ++- .../project/controllers/project.controller.js | 105 +++++++ .../repositories/project.repository.js | 187 ++++++++++++ .../project/services/project.service.js | 204 +++++++++++++ src/routes/auth.routes.js | 50 ++++ src/routes/project.routes.js | 165 +++++++++++ 14 files changed, 1295 insertions(+), 36 deletions(-) create mode 100644 prisma/seed.js 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;