diff --git a/README.md b/README.md index 2f453d4..29f91d3 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,7 @@ base url: `http://localhost:3000` - Delete a team: `DELETE /api/organization/:organizationId/team/:teamId` - Get all teams: `GET /api/organization/:organizationId/teams/all` - Get a specific team: `GET /api/organization/:organizationId/teams/:teamId` + +### Project + +- Create a new project: `POST /api/organization/:organizationId/team/:teamId/project` diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9a5673d..2f2c605 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -238,7 +238,7 @@ model ProjectMember { id String @id @default(uuid()) @db.Uuid projectId String @db.Uuid userId String @db.Uuid - role String @db.VarChar(50) // e.g., "DEVELOPER", "TESTER", "PRODUCT_OWNER" + role String @db.VarChar(50) // e.g., "DEVELOPER", "TESTER", "PRODUCT_OWNER" // make it as enum isActive Boolean @default(true) joinedAt DateTime @default(now()) leftAt DateTime? diff --git a/src/controllers/project.controller.js b/src/controllers/project.controller.js index c8fe236..0aeeda8 100644 --- a/src/controllers/project.controller.js +++ b/src/controllers/project.controller.js @@ -52,32 +52,6 @@ const checkOrganization = async (organizationId) => { }; }; -/** - * Helper function to check if department exists and is not deleted - * departmentId - The department ID to check - * returns - Contains success flag, error message, and department data - */ -const checkDepartment = async (departmentId) => { - const dep = await prisma.department.findFirst({ - where: { - id: departmentId, - deletedAt: null, - }, - }); - - if (!dep) { - return { - success: false, - message: 'Department not found', - }; - } - - return { - success: true, - department: dep, - }; -}; - /** * Helper function to check if department exists and is not deleted * @param {string} departmentId - The department ID to check @@ -132,13 +106,12 @@ const checkTeam = async ( * @param {Object} [options] - Additional options for the query * @returns {Promise} - Contains success flag, error message, and team data */ -const checkTeamPermissions = (user, organization, department, team, action) => { +const checkTeamPermissions = (user, organization, team, action) => { const isAdmin = user.role === 'ADMIN'; const isOwner = organization.owners.some((owner) => owner.userId === user.id); - const isDepManager = department.managerId === user.id; const isTeamManager = team.createdBy === user.id; - if (!isAdmin && !isOwner && !isDepManager && !isTeamManager) { + if (!isAdmin && !isOwner && !isTeamManager) { return { success: false, message: `You do not have permission to ${action} this team`, @@ -149,26 +122,25 @@ const checkTeamPermissions = (user, organization, department, team, action) => { success: true, isAdmin, isOwner, - isDepManager, isTeamManager, }; }; /** * @desc Create a new project - * @route /api/organization/:organizationId/department/:departmentId/team/:teamId/project + * @route /api/organization/:organizationId/team/:teamId/project * @method POST * @access private */ export const createProject = async (req, res, next) => { try { - const { organizationId, departmentId, teamId } = req.params; + const { organizationId, teamId } = req.params; // Validate required parameters - const paramsValidation = validateParams( - { organizationId, departmentId, teamId }, - ['organizationId', 'departmentId', 'teamId'], - ); + const paramsValidation = validateParams({ organizationId, teamId }, [ + 'organizationId', + 'teamId', + ]); if (!paramsValidation.success) { return res.status(400).json({ @@ -187,18 +159,8 @@ export const createProject = async (req, res, next) => { } const existingOrg = orgResult.organization; - // Check if department exists - const depResult = await checkDepartment(departmentId); - if (!depResult.success) { - return res.status(404).json({ - success: false, - message: depResult.message, - }); - } - const existingDep = depResult.department; - // Check if team exists - const teamResult = await checkTeam(teamId, organizationId, departmentId); + const teamResult = await checkTeam(teamId, organizationId); if (!teamResult.success) { return res.status(404).json({ success: false, @@ -211,7 +173,6 @@ export const createProject = async (req, res, next) => { const permissionCheck = checkTeamPermissions( req.user, existingOrg, - existingDep, team, 'create a project in', ); @@ -233,16 +194,93 @@ export const createProject = async (req, res, next) => { }); } - // const { - // name, - // description, - // status = 'PLANNING', - // startDate, - // endDate, - // priority = 'MEDIUM', - // budget = null, - // members = [], - // } = req.body; + const { + name, + description, + status = 'PLANNING', + startDate, + endDate, + priority = 'MEDIUM', + budget = null, + members = [], + } = req.body; + + if (new Date(startDate) >= new Date(endDate)) { + return res.status(400).json({ + success: false, + message: 'Start date must be before end date', + }); + } + + try { + const result = await prisma.$transaction(async (tx) => { + // 1. create project + const project = await tx.project.create({ + data: { + name, + description, + status, + startDate: new Date(startDate), + endDate: new Date(endDate), + priority, + budget, + teamId, + organizationId, + lastModifiedBy: req.user.id, + createdBy: req.user.id, + progress: 0, + }, + }); + + // 2. create project leader + const projectLeader = await tx.projectMember.create({ + data: { + projectId: project.id, + userId: req.user.id, + role: 'PROJECT_OWNER', + isActive: true, + }, + }); + + // 3. Create project members if any + const projectMembers = []; + if (members.length > 0) { + for (const member of members) { + const createMember = await tx.projectMember.create({ + data: { + projectId: project.id, + userId: member.userId, + role: member.role || 'MEMBER', + isActive: true, + }, + }); + projectMembers.push(createMember); + } + } + + return { project, projectLeader, projectMembers }; + }); + + res.status(201).json({ + success: true, + message: 'Project created successfully.', + data: { + project: result.project, + projectOwner: result.projectLeader, + members: result.projectMembers, + }, + }); + } 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 e63bb3a..35d9faa 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -3205,6 +3205,94 @@ } ] } + }, + "/api/organization/{organizationId}/team/{teamId}/project": { + "post": { + "tags": ["project"], + "summary": "Create a new project", + "description": "Creates a new project within a team. Requires appropriate permissions.", + "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" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProject" + } + } + } + }, + "responses": { + "201": { + "description": "Project created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCreatedResponse" + } + } + } + }, + "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 or team not found" + }, + "409": { + "description": "Conflict - Project with this name already exists" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } } }, @@ -4251,6 +4339,190 @@ } } } + }, + "CreateProject": { + "type": "object", + "required": ["name", "startDate", "endDate"], + "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" + ], + "default": "PLANNING" + }, + "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"], + "default": "MEDIUM" + }, + "budget": { + "type": "number", + "description": "Project budget", + "nullable": true, + "minimum": 0 + }, + "members": { + "type": "array", + "description": "Initial project members", + "items": { + "$ref": "#/components/schemas/ProjectMemberInput" + }, + "default": [] + } + } + }, + "ProjectMemberInput": { + "type": "object", + "required": ["userId"], + "properties": { + "userId": { + "type": "string", + "description": "User ID to add as project member" + }, + "role": { + "type": "string", + "description": "Member role", + "enum": ["MEMBER", "CONTRIBUTOR", "REVIEWER", "MANAGER"], + "default": "MEMBER" + } + } + }, + "ProjectCreatedResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Project created successfully." + }, + "data": { + "type": "object", + "properties": { + "project": { + "$ref": "#/components/schemas/ProjectBasic" + }, + "projectOwner": { + "$ref": "#/components/schemas/ProjectMember" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectMember" + } + } + } + } + } + }, + "ProjectBasic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "startDate": { + "type": "string", + "format": "date-time" + }, + "endDate": { + "type": "string", + "format": "date-time" + }, + "priority": { + "type": "string" + }, + "budget": { + "type": "number", + "nullable": true + }, + "teamId": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "progress": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "createdBy": { + "type": "string" + }, + "lastModifiedBy": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectMember": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "role": { + "type": "string" + }, + "isActive": { + "type": "boolean" + } + } } }, "securitySchemes": { diff --git a/src/index.js b/src/index.js index 45cd178..3fe92f9 100644 --- a/src/index.js +++ b/src/index.js @@ -15,7 +15,7 @@ import authRouter from './routes/auth.routes.js'; 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 projectRoutes from './routes/project.routes.js'; import { errorHandler, notFound, @@ -76,7 +76,7 @@ app.use(orgRouter); app.use(userRoutes); app.use(departmentRoutes); app.use(teamRoutes); -// app.use(projectRoutes); +app.use(projectRoutes); // Error handling middleware app.use(notFound); diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 4f3a553..9c5273f 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -5,7 +5,7 @@ import { createProject } from '../controllers/project.controller.js'; const router = Router(); router.post( - '/api/organization/:organizationId/department/:departmentId/team/:teamId/project', + '/api/organization/:organizationId/team/:teamId/project', verifyAccessToken, createProject, ); diff --git a/src/validations/project.validation.js b/src/validations/project.validation.js index 74cd678..ea723b4 100644 --- a/src/validations/project.validation.js +++ b/src/validations/project.validation.js @@ -1,7 +1,85 @@ import Joi from 'joi'; export const createProjectValidation = (obj) => { - const schema = Joi.object({}); + const schema = Joi.object({ + name: Joi.string().trim().min(3).max(100).required().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', + 'any.required': 'Project name is required', + }), + + 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') + .default('PLANNING') + .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().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', + }), + + priority: Joi.string() + .valid('LOW', 'MEDIUM', 'HIGH', 'URGENT') + .default('MEDIUM') + .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', + }), + + members: Joi.array() + .items( + Joi.object({ + userId: Joi.string().uuid().required().messages({ + 'string.base': 'User ID must be a string', + 'string.guid': 'User ID must be a valid UUID', + 'any.required': 'User ID is required for each member', + }), + role: Joi.string() + .valid('DEVELOPER', 'TESTER', 'DESIGNER', 'PRODUCT_OWNER', 'MEMBER') + .default('MEMBER') + .messages({ + 'string.base': 'Role must be a string', + 'any.only': + 'Role must be one of: DEVELOPER, TESTER, DESIGNER, PRODUCT_OWNER, MEMBER', + }), + }), + ) + .default([]) + .messages({ + 'array.base': 'Members must be an array', + }), + + progress: Joi.number().min(0).max(100).default(0).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 }); };