diff --git a/prisma/generated/prisma-client-js/libquery_engine-debian-openssl-3.0.x.so.node b/prisma/generated/prisma-client-js/libquery_engine-debian-openssl-3.0.x.so.node deleted file mode 100755 index 6986f38..0000000 Binary files a/prisma/generated/prisma-client-js/libquery_engine-debian-openssl-3.0.x.so.node and /dev/null differ diff --git a/src/controllers/sprint.controller.js b/src/controllers/sprint.controller.js new file mode 100644 index 0000000..14a617a --- /dev/null +++ b/src/controllers/sprint.controller.js @@ -0,0 +1,322 @@ +import prisma from '../config/prismaClient.js'; +import { sprintvalidation } from '../validations/sprint.validation.js'; + +/** + * Validate required parameters + * @param {Object} params - Parameters to validate + * @param {Array} requiredParams - Required parameter names + * @returns {Object} - Validation result + */ +const validateParams = (params, requiredParams) => { + for (const param of requiredParams) { + if (!params[param]) { + return { + success: false, + message: `${param.charAt(0).toUpperCase() + param.slice(1)} is required`, + }; + } + } + return { success: true }; +}; + +/** + * Check if project exists and user has access + * @param {string} projectId - Project ID + * @param {string} organizationId - Organization ID + * @param {string} teamId - Team ID + * @param {Object} user - User object + * @returns {Promise} - Contains project, organization and permission info + */ +const checkProjectAccess = async (projectId, organizationId, teamId, user) => { + // Check if organization exists + const organization = await prisma.organization.findFirst({ + where: { + id: organizationId, + deletedAt: null, + }, + include: { + owners: { + select: { + userId: true, + }, + }, + }, + }); + + if (!organization) { + return { + success: false, + message: 'Organization not found', + }; + } + + // Check if team exists + const team = await prisma.team.findFirst({ + where: { + id: teamId, + organizationId, + deletedAt: null, + }, + }); + + if (!team) { + return { + success: false, + message: 'Team not found', + }; + } + + // Check if project exists + const project = await prisma.project.findFirst({ + where: { + id: projectId, + teamId, + organizationId, + deletedAt: null, + }, + include: { + ProjectMember: { + where: { + userId: user.id, + leftAt: null, + }, + select: { + role: true, + }, + }, + }, + }); + + if (!project) { + return { + success: false, + message: 'Project not found', + }; + } + + // Check permissions + const isAdmin = user.role === 'ADMIN'; + const isOrgOwner = organization.owners.some( + (owner) => owner.userId === user.id, + ); + const isTeamManager = team.createdBy === user.id; + const isProjectOwner = project.ProjectMember.some( + (m) => m.role === 'PROJECT_OWNER', + ); + const isProjectManager = project.ProjectMember.some( + (m) => m.role === 'PROJECT_MANAGER', + ); + + const hasPermission = + isAdmin || + isOrgOwner || + isTeamManager || + isProjectOwner || + isProjectManager; + + return { + success: true, + project, + organization, + team, + hasPermission, + isAdmin, + isOrgOwner, + isTeamManager, + isProjectOwner, + isProjectManager, + }; +}; + +/** + * Validate sprint dates + * @param {Date} startDate - Sprint start date + * @param {Date} endDate - Sprint end date + * @param {string} projectId - Project ID + * @returns {Promise} - Validation result + */ +const validateSprintDates = async (startDate, endDate, projectId) => { + // Basic date validation + if (new Date(startDate) >= new Date(endDate)) { + return { + success: false, + message: 'Start date must be before end date', + }; + } + + // Check for overlapping sprints + const overlappingSprint = await prisma.sprint.findFirst({ + where: { + projectId, + OR: [ + { + // New sprint starts during existing sprint + startDate: { lte: new Date(endDate) }, + endDate: { gte: new Date(startDate) }, + }, + { + // New sprint encompasses existing sprint + startDate: { gte: new Date(startDate) }, + endDate: { lte: new Date(endDate) }, + }, + ], + }, + }); + + if (overlappingSprint) { + return { + success: false, + message: 'Sprint dates overlap with existing sprint', + overlappingSprint, + }; + } + + return { success: true }; +}; + +/** + * Determine sprint status based on dates + * @param {Date} startDate - Sprint start date + * @param {Date} endDate - Sprint end date + * @returns {string} - Sprint status + */ +const calculateSprintStatus = (startDate, endDate) => { + const now = new Date(); + const start = new Date(startDate); + const end = new Date(endDate); + + if (now < start) { + return 'PLANNING'; + } + if (now >= start && now <= end) { + return 'ACTIVE'; + } + return 'COMPLETED'; +}; + +/** + * @desc Create a new sprint + * @route POST /api/organization/:organizationId/team/:teamId/project/:projectId/sprint + * @method POST + * @access private + */ +export const createSprint = async (req, res, next) => { + try { + const { organizationId, teamId, projectId } = req.params; + const user = req.user; + + // Validate required parameters + const paramsValidation = validateParams( + { organizationId, teamId, projectId }, + ['organizationId', 'teamId', 'projectId'], + ); + + if (!paramsValidation.success) { + return res.status(400).json({ + success: false, + message: paramsValidation.message, + }); + } + + // Check project access and permissions + const accessCheck = await checkProjectAccess( + projectId, + organizationId, + teamId, + user, + ); + if (!accessCheck.success) { + return res + .status(accessCheck.message === 'Project not found' ? 404 : 403) + .json({ + success: false, + message: accessCheck.message, + }); + } + + if (!accessCheck.hasPermission) { + return res.status(403).json({ + success: false, + message: 'You do not have permission to create sprints in this project', + }); + } + + // Validate input + const { error } = sprintvalidation(req.body); + if (error) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: error.details.map((e) => e.message), + }); + } + + const { name, description, startDate, endDate, goal } = req.body; + + // Validate sprint dates + const dateValidation = await validateSprintDates( + startDate, + endDate, + projectId, + ); + if (!dateValidation.success) { + return res.status(400).json({ + success: false, + message: dateValidation.message, + data: dateValidation.overlappingSprint + ? { overlappingSprint: dateValidation.overlappingSprint } + : undefined, + }); + } + + // Calculate status based on dates + const status = calculateSprintStatus(startDate, endDate); + + try { + // Get the highest current order value to place new sprint at the end + const lastSprint = await prisma.sprint.findFirst({ + where: { + projectId, + }, + orderBy: { + order: 'desc', + }, + select: { + order: true, + }, + }); + + const newOrder = lastSprint ? lastSprint.order + 1 : 0; + + // Create the sprint + const sprint = await prisma.sprint.create({ + data: { + name, + description, + startDate: new Date(startDate), + endDate: new Date(endDate), + status, + goal, + order: newOrder, + projectId, + }, + }); + + res.status(201).json({ + success: true, + message: 'Sprint created successfully', + data: sprint, + }); + } catch (error) { + if (error.code === 'P2002') { + return res.status(400).json({ + success: false, + message: 'A sprint with this name already exists in this project', + }); + } + throw error; + } + } catch (error) { + next(error); + } +}; diff --git a/src/index.js b/src/index.js index b1f4495..4fe1ca3 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,7 @@ import userRoutes from './routes/user.routes.js'; import orgRouter from './routes/organization.routes.js'; import teamRoutes from './routes/team.routes.js'; import projectRoutes from './routes/project.routes.js'; +import sprintRoutes from './routes/sprint.routes.js'; import taskRoutes from './routes/task.routes.js'; import { errorHandler, @@ -55,7 +56,8 @@ app.use(passport.session()); // Cors Policy app.use( cors({ - credentials: true, // allow cookies + origin: '*', // Allow all origins or specify allowed origins + credentials: true, // Allow cookies }), ); @@ -80,6 +82,7 @@ app.use(userRoutes); app.use(departmentRoutes); app.use(teamRoutes); app.use(projectRoutes); +app.use(sprintRoutes); app.use(taskRoutes); // Error handling middleware diff --git a/src/routes/sprint.routes.js b/src/routes/sprint.routes.js new file mode 100644 index 0000000..0093d4c --- /dev/null +++ b/src/routes/sprint.routes.js @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { createSprint } from '../controllers/sprint.controller.js'; +import { verifyAccessToken } from '../middlewares/auth.middleware.js'; + +const router = Router(); + +router.post( + '/api/organization/:organizationId/team/:teamId/project/:projectId/sprint/create', + verifyAccessToken, + createSprint, +); + +export default router; diff --git a/src/validations/sprint.validation.js b/src/validations/sprint.validation.js new file mode 100644 index 0000000..f55dcb7 --- /dev/null +++ b/src/validations/sprint.validation.js @@ -0,0 +1,130 @@ +import Joi from 'joi'; + +export const sprintvalidation = (obj) => { + const schema = Joi.object({ + name: Joi.string().trim().min(2).max(100).required().messages({ + 'string.base': 'Sprint name must be a string', + 'string.empty': 'Sprint name cannot be empty', + 'string.min': 'Sprint name must be at least 2 characters long', + 'string.max': 'Sprint name cannot exceed 100 characters', + 'any.required': 'Sprint name is required', + }), + + description: Joi.string().trim().allow('').max(2000).messages({ + 'string.base': 'Description must be a string', + 'string.max': 'Description cannot exceed 2000 characters', + }), + + startDate: Joi.date().iso().required().messages({ + 'date.base': 'Start date must be a valid date', + 'date.format': 'Start date must be in ISO format (YYYY-MM-DD)', + 'any.required': 'Start date is required', + }), + + endDate: Joi.date().iso().min(Joi.ref('startDate')).required().messages({ + 'date.base': 'End date must be a valid date', + 'date.format': 'End date must be in ISO format (YYYY-MM-DD)', + 'date.min': 'End date must be after start date', + 'any.required': 'End date is required', + }), + + goal: Joi.string().trim().allow('').max(500).messages({ + 'string.base': 'Goal must be a string', + 'string.max': 'Goal cannot exceed 500 characters', + }), + + status: Joi.string() + .valid('PLANNING', 'ACTIVE', 'COMPLETED') + .default('PLANNING') + .messages({ + 'string.base': 'Status must be a string', + 'any.only': 'Status must be one of: PLANNING, ACTIVE, COMPLETED', + }), + + order: Joi.number().integer().min(0).default(0).messages({ + 'number.base': 'Order must be a number', + 'number.integer': 'Order must be an integer', + 'number.min': 'Order cannot be negative', + }), + }); + + return schema.validate(obj, { abortEarly: false }); +}; + +export const updateSprintValidation = { + validate: (obj) => { + const schema = Joi.object({ + name: Joi.string().trim().min(2).max(100).messages({ + 'string.base': 'Sprint name must be a string', + 'string.empty': 'Sprint name cannot be empty', + 'string.min': 'Sprint name must be at least 2 characters long', + 'string.max': 'Sprint name cannot exceed 100 characters', + }), + + description: Joi.string().trim().allow('').max(2000).messages({ + 'string.base': 'Description must be a string', + 'string.max': 'Description cannot exceed 2000 characters', + }), + + startDate: Joi.date().iso().messages({ + 'date.base': 'Start date must be a valid date', + 'date.format': 'Start date must be in ISO format (YYYY-MM-DD)', + }), + + endDate: Joi.date().iso().messages({ + 'date.base': 'End date must be a valid date', + 'date.format': 'End date must be in ISO format (YYYY-MM-DD)', + }), + + goal: Joi.string().trim().allow('').max(500).messages({ + 'string.base': 'Goal must be a string', + 'string.max': 'Goal cannot exceed 500 characters', + }), + + status: Joi.string().valid('PLANNING', 'ACTIVE', 'COMPLETED').messages({ + 'string.base': 'Status must be a string', + 'any.only': 'Status must be one of: PLANNING, ACTIVE, COMPLETED', + }), + + order: Joi.number().integer().min(0).messages({ + 'number.base': 'Order must be a number', + 'number.integer': 'Order must be an integer', + 'number.min': 'Order cannot be negative', + }), + }) + .min(1) + .messages({ + 'object.min': 'At least one field must be provided for update', + }); + + return schema.validate(obj, { abortEarly: false }); + }, +}; + +export const updateSprintStatusValidation = (obj) => { + const schema = Joi.object({ + status: Joi.string() + .valid('PLANNING', 'ACTIVE', 'COMPLETED') + .required() + .messages({ + 'string.base': 'Status must be a string', + 'any.only': 'Status must be one of: PLANNING, ACTIVE, COMPLETED', + 'any.required': 'Status is required', + }), + }); + + return schema.validate(obj, { abortEarly: false }); +}; + +export const sprintTasksValidation = (obj) => { + const schema = Joi.object({ + taskIds: Joi.array().items(Joi.string().uuid()).min(1).required().messages({ + 'array.base': 'Task IDs must be an array', + 'array.min': 'At least one task ID must be provided', + 'string.guid': 'Each task ID must be a valid UUID', + 'any.required': 'Task IDs are required', + }), + }); + + return schema.validate(obj, { abortEarly: false }); +};