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