diff --git a/src/controllers/team.controller.js b/src/controllers/team.controller.js new file mode 100644 index 0000000..d8878d2 --- /dev/null +++ b/src/controllers/team.controller.js @@ -0,0 +1,202 @@ +import prisma from '../config/prismaClient.js'; +import { createTeamValidation } from '../validations/team.validation.js'; + +/** + * @desc Create a new team in a specific organization + * @route /api/organization + * @method POST + * @access private - admins or organization owners only + */ +export const createTeam = async (req, res, next) => { + try { + // POST /api/organization/:organizationId/department/:departmentId/team + const { organizationId, departmentId } = req.params; + + if (!organizationId) { + return res.status(400).json({ + success: false, + message: 'Organization ID is required', + }); + } + + // Check if organization exists and is not deleted + const existingOrg = await prisma.organization.findFirst({ + where: { + id: organizationId, + deletedAt: null, + }, + include: { + owners: { + select: { + userId: true, + }, + }, + }, + }); + + if (!existingOrg) { + return res.status(404).json({ + success: false, + message: 'Organization not found', + }); + } + + if (!departmentId) { + return res.status(400).json({ + success: false, + message: 'Department ID is required', + }); + } + + // Check if department exists and is not deleted + const existingDep = await prisma.department.findFirst({ + where: { + id: departmentId, + deletedAt: null, + }, + }); + + if (!existingDep) { + return res.status(404).json({ + success: false, + message: 'Department not found', + }); + } + + // Check permissions - only admins and organization owners + const isAdmin = req.user.role === 'ADMIN'; + const isOwner = existingOrg.owners.some( + (owner) => owner.userId === req.user.id, + ); + const isDepManager = existingDep.managerId === req.user.id; + + if (!isAdmin && !isOwner && !isDepManager) { + return res.status(403).json({ + success: false, + message: + 'You do not have permission to create teams in this department', + }); + } + + // Validate input + const { error } = createTeamValidation(req.body); + if (error) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: error.details.map((e) => e.message), + }); + } + + const { name, description, avatar, members = [] } = req.body; + + const existingTeam = await prisma.team.findFirst({ + where: { + name: name, + organizationId: organizationId, + deletedAt: null, + }, + }); + if (existingTeam) { + return res.status(409).json({ + success: false, + message: 'Team with this name already exists', + }); + } + + const result = await prisma.$transaction(async (tx) => { + // 1. create the team + const team = await tx.team.create({ + data: { + name, + description, + avatar, + createdBy: req.user.id, + organizationId: organizationId, + departmentId: departmentId, + }, + }); + + // 2. create team member + const leaderMember = await tx.teamMember.create({ + data: { + teamId: team.id, + userId: req.user.id, + role: 'LEADER', + isActive: true, + }, + }); + + // 3. create additional team members if provided + const additionalMembers = []; + if (members && members.length > 0) { + for (const member of members) { + // Check if user exists + const userExists = await tx.user.findFirst({ + where: { id: member.userId, deletedAt: null }, + select: { id: true }, + }); + + if (userExists) { + const newMember = await tx.teamMember.create({ + data: { + teamId: team.id, + userId: member.userId, + role: member.role || 'MEMBER', + isActive: true, + }, + }); + additionalMembers.push(newMember); + } + } + } + + // 4. fetch all team members for the response + const allTeamMembers = await tx.teamMember.findMany({ + where: { teamId: team.id }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + avatar: true, + }, + }, + }, + }); + + return { team, leaderMember, allTeamMembers }; + }); + + return res.status(201).json({ + success: true, + message: `Team created successfully.`, + data: { + team: { + id: result.team.id, + name: result.team.name, + description: result.team.description, + }, + teamLeader: { + id: result.team.createdBy, + leader: result.leaderMember, + }, + teamMembers: result.allTeamMembers.map((member) => ({ + id: member.id, + userId: member.userId, + role: member.role, + user: member.user, + })), + }, + }); + } catch (error) { + if (error.code === 'P2002') { + return res.status(409).json({ + success: false, + message: 'Team with this name already exists in this organization', + }); + } + next(error); + } +}; diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 4921c22..8ba3223 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -29,6 +29,10 @@ { "name": "Users", "description": "User management" + }, + { + "name": "Team", + "description": "Team management" } ], "paths": { @@ -1280,6 +1284,160 @@ } } } + }, + "/api/organization/{organizationId}/department/{departmentId}/team": { + "post": { + "tags": ["Team"], + "summary": "Create a new team", + "description": "Create a new team within a specific department of an organization. Requires admin privileges, organization ownership, or department management rights.", + "operationId": "createTeam", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "ID of the organization", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "departmentId", + "in": "path", + "required": true, + "description": "ID of the department", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Team creation data", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamRequest" + }, + "examples": { + "basicTeam": { + "value": { + "name": "Frontend Development", + "description": "Team responsible for frontend development", + "avatar": "https://example.com/team-avatar.jpg", + "members": [ + { + "userId": "550e8400-e29b-41d4-a716-446655440001", + "role": "MEMBER" + } + ] + } + }, + "minimalTeam": { + "value": { + "name": "Backend Team", + "description": "Team handling backend services" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Team created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamResponse" + } + } + } + }, + "400": { + "description": "Bad request - validation error or missing parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "validationError": { + "value": { + "success": false, + "message": "Validation failed", + "errors": ["\"name\" is required"] + } + }, + "missingParams": { + "value": { + "success": false, + "message": "Organization ID and Department ID are required" + } + } + } + } + } + }, + "403": { + "description": "Forbidden - insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Organization or department not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "orgNotFound": { + "value": { + "success": false, + "message": "Organization not found" + } + }, + "depNotFound": { + "value": { + "success": false, + "message": "Department not found" + } + } + } + } + } + }, + "409": { + "description": "Conflict - team name already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } } }, "components": { @@ -2245,6 +2403,162 @@ "example": "newPassword456" } } + }, + "CreateTeamRequest": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 2, + "maxLength": 100, + "example": "Frontend Team" + }, + "description": { + "type": "string", + "maxLength": 500, + "example": "Team responsible for frontend development" + }, + "avatar": { + "type": "string", + "format": "uri", + "example": "https://example.com/team-avatar.jpg" + }, + "members": { + "type": "array", + "items": { + "type": "object", + "required": ["userId"], + "properties": { + "userId": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440001" + }, + "role": { + "type": "string", + "enum": ["LEADER", "MEMBER", "CONTRIBUTOR"], + "default": "MEMBER" + } + } + } + } + } + }, + "CreateTeamResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Team created successfully" + }, + "data": { + "type": "object", + "properties": { + "team": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "teamLeader": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "leader": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "role": { + "type": "string" + } + } + } + } + }, + "teamMembers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "role": { + "type": "string" + }, + "user": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "avatar": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "TeamMember": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "role": { + "type": "string", + "enum": ["LEADER", "MEMBER", "CONTRIBUTOR"] + }, + "isActive": { + "type": "boolean" + } + } } }, diff --git a/src/index.js b/src/index.js index fe3d2a8..dd38969 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ import swaggerUi from 'swagger-ui-express'; 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 { errorHandler, notFound, @@ -65,6 +66,7 @@ app.use(express.urlencoded({ extended: true })); app.use(authRouter); app.use(orgRouter); app.use(userRoutes); +app.use(teamRoutes); // Error handling middleware app.use(notFound); diff --git a/src/routes/team.routes.js b/src/routes/team.routes.js new file mode 100644 index 0000000..a50aec0 --- /dev/null +++ b/src/routes/team.routes.js @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { verifyAccessToken } from '../middlewares/auth.middleware.js'; +import { createTeam } from '../controllers/team.controller.js'; + +const router = Router(); + +router.post( + '/api/organization/:organizationId/department/:departmentId/team', + verifyAccessToken, + createTeam, +); + +export default router; diff --git a/src/validations/team.validation.js b/src/validations/team.validation.js new file mode 100644 index 0000000..ee18c85 --- /dev/null +++ b/src/validations/team.validation.js @@ -0,0 +1,40 @@ +import Joi from 'joi'; + +export const createTeamValidation = (obj) => { + const schema = Joi.object({ + name: Joi.string().min(2).max(100).required().trim().messages({ + 'string.base': 'Team name must be a string', + 'string.empty': 'Team name is required', + 'string.min': 'Team name must be at least {#limit} characters long', + 'string.max': 'Team name cannot exceed {#limit} characters', + 'any.required': 'Team name is required', + }), + + description: Joi.string().allow('').optional().messages({ + 'string.base': 'Description must be a string', + }), + + avatar: Joi.string().allow(null, '').optional().messages({ + 'string.base': 'Avatar must be a string URL or file path', + }), + + members: Joi.array() + .items( + Joi.object({ + userId: Joi.string().uuid().required().messages({ + 'string.guid': 'User ID must be a valid UUID', + 'any.required': 'User ID is required for team members', + }), + role: Joi.string() + .valid('MEMBER', 'LEADER', 'VIEWER') + .default('MEMBER') + .messages({ + 'any.only': 'Role must be one of: MEMBER, LEADER, or VIEWER', + }), + }), + ) + .optional(), + }); + + return schema.validate(obj, { abortEarly: false }); +};