diff --git a/README.md b/README.md index 29f91d3..555052a 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,4 @@ base url: `http://localhost:3000` ### Project - Create a new project: `POST /api/organization/:organizationId/team/:teamId/project` +- Update a project: `PUT /api/organization/:organizationId/team/:teamId/project/:projectId` diff --git a/src/controllers/project.controller.js b/src/controllers/project.controller.js index 0aeeda8..fef5a81 100644 --- a/src/controllers/project.controller.js +++ b/src/controllers/project.controller.js @@ -1,5 +1,8 @@ import prisma from '../config/prismaClient.js'; -import { createProjectValidation } from '../validations/project.validation.js'; +import { + createProjectValidation, + updateProjectValidation, +} from '../validations/project.validation.js'; /** * Helper function to validate required params @@ -285,3 +288,217 @@ export const createProject = async (req, res, next) => { next(error); } }; + +/** + * @desc Update a project + * @route /api/organization/:organizationId/team/:teamId/project/:projectId + * @method PUT + * @access private + */ +export const updateProject = async (req, res, next) => { + try { + const { organizationId, teamId, projectId } = req.params; + + // 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 if organization exists + const orgResult = await checkOrganization(organizationId); + if (!orgResult.success) { + return res.status(404).json({ + success: false, + message: orgResult.message, + }); + } + const existingOrg = orgResult.organization; + + // Check if team exists + const teamResult = await checkTeam(teamId, organizationId); + if (!teamResult.success) { + return res.status(404).json({ + success: false, + message: teamResult.message, + }); + } + const team = teamResult.team; + + // Check if project exists + const existingProject = await prisma.project.findFirst({ + where: { + id: projectId, + teamId: teamId, + organizationId: organizationId, + deletedAt: null, + }, + }); + + if (!existingProject) { + return res.status(404).json({ + success: false, + message: 'Project not found', + }); + } + + // Check permissions (can be done via a similar helper function as in create) + const permissionCheck = checkTeamPermissions( + req.user, + existingOrg, + team, + 'update', + ); + + if (!permissionCheck.success) { + return res.status(403).json({ + success: false, + message: permissionCheck.message, + }); + } + + // Additional project-specific permissions check if needed + const isProjectOwner = await prisma.projectMember.findFirst({ + where: { + projectId: projectId, + userId: req.user.id, + role: 'PROJECT_OWNER', + isActive: true, + }, + }); + + const hasPermission = + permissionCheck.isAdmin || + permissionCheck.isOwner || + permissionCheck.isTeamManager || + isProjectOwner; + + if (!hasPermission) { + return res.status(403).json({ + success: false, + message: 'You do not have permission to update this project', + }); + } + + // Validate input + const { error } = updateProjectValidation(req.body); + if (error) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: error.details.map((e) => e.message), + }); + } + + const { + name, + description, + status, + startDate, + endDate, + priority, + budget, + progress, + } = req.body; + + // Check date validation if both dates are provided + if (startDate && endDate) { + const normalizedStartDate = new Date(startDate); + normalizedStartDate.setHours(0, 0, 0, 0); + + const normalizedEndDate = new Date(endDate); + normalizedEndDate.setHours(0, 0, 0, 0); + + if (normalizedStartDate >= normalizedEndDate) { + return res.status(400).json({ + success: false, + message: 'Start date must be before end date', + }); + } + } + + try { + // Construct update data with only the fields that were provided + const updateData = {}; + + if (name !== undefined) { + updateData.name = name; + } + if (description !== undefined) { + updateData.description = description; + } + if (status !== undefined) { + updateData.status = status; + } + if (priority !== undefined) { + updateData.priority = priority; + } + if (budget !== undefined) { + updateData.budget = budget; + } + if (progress !== undefined) { + updateData.progress = progress; + } + if (startDate !== undefined) { + updateData.startDate = new Date(startDate); + } + if (endDate !== undefined) { + updateData.endDate = new Date(endDate); + } + + // Always update lastModifiedBy + updateData.lastModifiedBy = req.user.id; + + // Check for name uniqueness if name is being updated + if (name && name !== existingProject.name) { + const duplicateProject = await prisma.project.findFirst({ + where: { + organizationId, + name, + id: { not: projectId }, + deletedAt: null, + }, + }); + + if (duplicateProject) { + return res.status(400).json({ + success: false, + message: + 'A project with this name already exists in this organization', + }); + } + } + + // Update the project + const updatedProject = await prisma.project.update({ + where: { id: projectId }, + data: updateData, + }); + + res.status(200).json({ + success: true, + message: 'Project updated successfully', + data: updatedProject, + }); + } catch (error) { + // Handle unique constraint violation + if (error.code === 'P2002') { + return res.status(400).json({ + success: false, + message: + 'A project with this name already exists in this organization', + }); + } + throw error; + } + } catch (error) { + next(error); + } +}; diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 35d9faa..b8e908f 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -37,6 +37,10 @@ { "name": "Team", "description": "Team management" + }, + { + "name": "Project", + "description": "Project management" } ], "paths": { @@ -3208,7 +3212,7 @@ }, "/api/organization/{organizationId}/team/{teamId}/project": { "post": { - "tags": ["project"], + "tags": ["Project"], "summary": "Create a new project", "description": "Creates a new project within a team. Requires appropriate permissions.", "parameters": [ @@ -3293,6 +3297,103 @@ } ] } + }, + "/api/organization/{organizationId}/team/{teamId}/project/{projectId}": { + "put": { + "tags": ["Project"], + "summary": "Update a project", + "description": "Updates an existing project. Requires appropriate permissions (admin, organization owner, team manager, or project owner).", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Organization ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "description": "Project ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProject" + } + } + } + }, + "responses": { + "200": { + "description": "Project updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectUpdatedResponse" + } + } + } + }, + "400": { + "description": "Bad request - Invalid input or validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Validation failed" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden - Insufficient permissions" + }, + "404": { + "description": "Not found - Organization, team or project not found" + }, + "409": { + "description": "Conflict - Project with this name already exists" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } } }, @@ -4523,6 +4624,77 @@ "type": "boolean" } } + }, + "UpdateProject": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Project name", + "minLength": 3, + "maxLength": 100 + }, + "description": { + "type": "string", + "description": "Project description", + "nullable": true, + "maxLength": 1000 + }, + "status": { + "type": "string", + "description": "Project status", + "enum": [ + "PLANNING", + "IN_PROGRESS", + "ON_HOLD", + "COMPLETED", + "CANCELLED" + ] + }, + "startDate": { + "type": "string", + "format": "date-time", + "description": "Project start date" + }, + "endDate": { + "type": "string", + "format": "date-time", + "description": "Project end date" + }, + "priority": { + "type": "string", + "description": "Project priority", + "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"] + }, + "budget": { + "type": "number", + "description": "Project budget", + "nullable": true, + "minimum": 0 + }, + "progress": { + "type": "integer", + "description": "Project progress percentage", + "minimum": 0, + "maximum": 100 + } + } + }, + "ProjectUpdatedResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Project updated successfully" + }, + "data": { + "$ref": "#/components/schemas/ProjectBasic" + } + } } }, "securitySchemes": { diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 9c5273f..d0daef5 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -1,6 +1,9 @@ import { Router } from 'express'; import { verifyAccessToken } from '../middlewares/auth.middleware.js'; -import { createProject } from '../controllers/project.controller.js'; +import { + createProject, + updateProject, +} from '../controllers/project.controller.js'; const router = Router(); @@ -10,4 +13,10 @@ router.post( createProject, ); +router.put( + '/api/organization/:organizationId/team/:teamId/project/:projectId', + verifyAccessToken, + updateProject, +); + export default router; diff --git a/src/validations/project.validation.js b/src/validations/project.validation.js index ea723b4..ccc3fb1 100644 --- a/src/validations/project.validation.js +++ b/src/validations/project.validation.js @@ -83,3 +83,56 @@ export const createProjectValidation = (obj) => { return schema.validate(obj, { abortEarly: false }); }; + +export const updateProjectValidation = (obj) => { + const schema = Joi.object({ + name: Joi.string().trim().min(3).max(100).messages({ + 'string.base': 'Project name must be a string', + 'string.empty': 'Project name cannot be empty', + 'string.min': 'Project name must be at least 3 characters long', + 'string.max': 'Project name cannot exceed 100 characters', + }), + + description: Joi.string().trim().allow('').max(1000).messages({ + 'string.base': 'Description must be a string', + 'string.max': 'Description cannot exceed 1000 characters', + }), + + status: Joi.string() + .valid('PLANNING', 'ACTIVE', 'ON_HOLD', 'COMPLETED', 'CANCELED') + .messages({ + 'string.base': 'Status must be a string', + 'any.only': + 'Status must be one of: PLANNING, ACTIVE, ON_HOLD, COMPLETED, CANCELED', + }), + + 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)', + }), + + priority: Joi.string().valid('LOW', 'MEDIUM', 'HIGH', 'URGENT').messages({ + 'string.base': 'Priority must be a string', + 'any.only': 'Priority must be one of: LOW, MEDIUM, HIGH, URGENT', + }), + + budget: Joi.number().precision(2).min(0).allow(null).messages({ + 'number.base': 'Budget must be a number', + 'number.min': 'Budget cannot be negative', + 'number.precision': 'Budget cannot have more than 2 decimal places', + }), + + progress: Joi.number().min(0).max(100).messages({ + 'number.base': 'Progress must be a number', + 'number.min': 'Progress cannot be less than 0', + 'number.max': 'Progress cannot exceed 100', + }), + }); + + return schema.validate(obj, { abortEarly: false }); +};