diff --git a/src/controllers/team.controller.js b/src/controllers/team.controller.js index 2da0ea2..542d5dc 100644 --- a/src/controllers/team.controller.js +++ b/src/controllers/team.controller.js @@ -1,4 +1,5 @@ import prisma from '../config/prismaClient.js'; +import { uploadToCloudinary } from '../utils/cloudinary.utils.js'; import { addTeamMemberValidation, createTeamValidation, @@ -520,3 +521,118 @@ export const updateTeam = async (req, res, next) => { next(error); } }; + +/** + * @desc Upload team avatar + * @route /api/organization/:organizationId/department/:departmentId/team/:teamId/avatar/upload + * @method POST + * @access private - admins or organization owners only + */ +export const uploadTeamAvatar = async (req, res, next) => { + try { + const { organizationId, departmentId, teamId } = req.params; + + if (!organizationId || !departmentId || !teamId) { + return res.status(400).json({ + success: false, + message: 'Organization ID, Department ID, and Team ID are 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', + }); + } + + // Check if department exists and is not deleted + const existingDep = await prisma.department.findFirst({ + where: { + id: departmentId, + deletedAt: null, + }, + select: { managerId: true }, + }); + + if (!existingDep) { + return res.status(404).json({ + success: false, + message: 'Department not found', + }); + } + + // Check if team exists and is not deleted + const team = await prisma.team.findFirst({ + where: { + id: teamId, + organizationId, + deletedAt: null, + }, + select: { + id: true, + name: true, + description: true, + createdBy: true, + }, + }); + if (!team) { + return res.status(404).json({ + success: false, + message: 'Team not found', + }); + } + + // TODO: Extract all permission checks into a helper function like hasTeamAddPermission(user, org, dep, team) to simplify controller logic. + // 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; + const isTeamManager = team.createdBy === req.user.id; + + if (!isAdmin && !isOwner && !isDepManager && !isTeamManager) { + return res.status(403).json({ + success: false, + message: + 'You do not have permission to update this team in this department', + }); + } + + if (!req.file) { + return res.status(400).json({ message: 'No file uploaded' }); + } + + const avatar = await uploadToCloudinary(req.file.buffer, 'team_avatar'); + + // Upload the team avatar + const updatedTeam = await prisma.team.update({ + where: { id: teamId }, + data: { avatar }, + }); + + res.status(200).json({ + success: true, + message: 'Team avatar uploaded successfully', + team: updatedTeam, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 57968bb..e6736de 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -2019,6 +2019,150 @@ } } } + }, + "/api/organization/{organizationId}/department/{departmentId}/team/{teamId}/avatar/upload": { + "post": { + "tags": ["Team"], + "summary": "Upload team avatar", + "description": "Upload an avatar image for a team. Requires admin privileges, organization ownership, department management, or team leadership rights.", + "operationId": "uploadTeamAvatar", + "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" + } + }, + { + "name": "teamId", + "in": "path", + "required": true, + "description": "ID of the team", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string", + "format": "binary", + "description": "Avatar image file (JPG/PNG)" + } + }, + "required": ["image"] + } + } + } + }, + "responses": { + "200": { + "description": "Team avatar uploaded successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamAvatarUploadResponse" + } + } + } + }, + "400": { + "description": "Bad request - missing parameters or no file uploaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "missingParams": { + "value": { + "success": false, + "message": "Organization ID, Department ID, and Team ID are required" + } + }, + "noFile": { + "value": { + "success": false, + "message": "No file uploaded" + } + } + } + } + } + }, + "403": { + "description": "Forbidden - insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Organization, department or team 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" + } + }, + "teamNotFound": { + "value": { + "success": false, + "message": "Team not found" + } + } + } + } + } + }, + "500": { + "description": "Internal server error - failed to upload image", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } } }, "components": { @@ -3629,6 +3773,58 @@ } } } + }, + "TeamAvatarUploadResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Team avatar uploaded successfully" + }, + "team": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "avatar": { + "type": "string", + "format": "uri" + }, + "organizationId": { + "type": "string", + "format": "uuid" + }, + "departmentId": { + "type": "string", + "format": "uuid" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + } + } } } } diff --git a/src/routes/team.routes.js b/src/routes/team.routes.js index 850063f..96c6bb2 100644 --- a/src/routes/team.routes.js +++ b/src/routes/team.routes.js @@ -4,7 +4,9 @@ import { addTeamMember, createTeam, updateTeam, + uploadTeamAvatar, } from '../controllers/team.controller.js'; +import upload from '../middlewares/upload.middleware.js'; const router = Router(); @@ -26,4 +28,11 @@ router.put( updateTeam, ); +router.post( + '/api/organization/:organizationId/department/:departmentId/team/:teamId/avatar/upload', + verifyAccessToken, + upload.single('image'), + uploadTeamAvatar, +); + export default router;