diff --git a/README.md b/README.md index cd08df2..3ecdd87 100644 --- a/README.md +++ b/README.md @@ -59,3 +59,8 @@ base url: `http://localhost:3000` - Create a new team in a specific organization: `POST /api/organization/:organizationId/department/:departmentId/team` - Add new team members: `POST /api/organization/:organizationId/department/:departmentId/team/:teamId/addMember` +- Remove member from a team: `DELETE /api/organization/:organizationId/department/:departmentId/team/:teamId/members/:memberId` +- Update a team: `PUT /api/organization/:organizationId/department/:departmentId/team/:teamId` +- Upload team avatar: `POST /api/organization/:organizationId/department/:departmentId/team/:teamId/avatar/upload` +- Delete team avatar: `DELETE /api/organization/:organizationId/department/:departmentId/team/:teamId/avatar/delete` +- Delete a team: `DELETE /api/organization/:organizationId/department/:departmentId/team/:teamId` diff --git a/prisma/migrations/20250407182922_add_delete_at_to_team_member_model/migration.sql b/prisma/migrations/20250407182922_add_delete_at_to_team_member_model/migration.sql new file mode 100644 index 0000000..8600c5b --- /dev/null +++ b/prisma/migrations/20250407182922_add_delete_at_to_team_member_model/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "team_members" ADD COLUMN "deletedAt" TIMESTAMP(3); diff --git a/prisma/schema/models/team.model.prisma b/prisma/schema/models/team.model.prisma index 9e3f0e7..4a41e06 100644 --- a/prisma/schema/models/team.model.prisma +++ b/prisma/schema/models/team.model.prisma @@ -27,12 +27,13 @@ model Team { } model TeamMember { - id String @id @default(uuid()) @db.Uuid - teamId String @db.Uuid - userId String @db.Uuid - role TeamMemberRole - joinedAt DateTime @default(now()) - isActive Boolean @default(true) + id String @id @default(uuid()) @db.Uuid + teamId String @db.Uuid + userId String @db.Uuid + role TeamMemberRole + joinedAt DateTime @default(now()) + isActive Boolean @default(true) + deletedAt DateTime? // Relations team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) diff --git a/src/controllers/team.controller.js b/src/controllers/team.controller.js index 7210a17..873744c 100644 --- a/src/controllers/team.controller.js +++ b/src/controllers/team.controller.js @@ -406,6 +406,183 @@ export const addTeamMember = async (req, res, next) => { } }; +/** + * @desc Remove member from a team (soft delete) + * @route /api/organization/:organizationId/department/:departmentId/team/:teamId/members/:memberId + * @method DELETE + * @access private - admins, organization owners, department managers, or team creators + */ +export const removeTeamMember = async (req, res, next) => { + try { + const { organizationId, departmentId, teamId, memberId } = req.params; + + if (!organizationId || !departmentId || !teamId || !memberId) { + return res.status(400).json({ + success: false, + message: + 'Organization ID, Department ID, Team ID, and Member 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, + }, + }); + + 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, + departmentId, + deletedAt: null, + }, + select: { + id: true, + name: true, + createdBy: true, + }, + }); + + if (!team) { + return res.status(404).json({ + success: false, + message: 'Team not found', + }); + } + + // Check if team member exists + const teamMember = await prisma.teamMember.findFirst({ + where: { + id: memberId, + teamId, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + }); + + if (!teamMember) { + return res.status(404).json({ + success: false, + message: 'Team member not found or already removed', + }); + } + + // Check permissions - admins, org owners, dep managers, or team creators can remove members + 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 isTeamCreator = team.createdBy === req.user.id; + + if (!isAdmin && !isOwner && !isDepManager && !isTeamCreator) { + return res.status(403).json({ + success: false, + message: 'You do not have permission to remove members from this team', + }); + } + + // Prevent removing the team creator if they're the only leader + if (teamMember.userId === team.createdBy) { + // Check if there are other leaders in the team + const otherLeaders = await prisma.teamMember.findMany({ + where: { + teamId, + role: 'LEADER', + userId: { not: team.createdBy }, + }, + }); + + if (otherLeaders.length === 0) { + return res.status(400).json({ + success: false, + message: + 'Cannot remove the only team leader. Please assign another leader first.', + }); + } + } + + // Soft delete the team member + const removedMember = await prisma.teamMember.update({ + where: { id: memberId }, + data: { deletedAt: new Date(), isActive: false }, + include: { + user: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }); + + return res.status(200).json({ + success: true, + message: `Team member ${removedMember.user.firstName} ${removedMember.user.lastName} removed successfully`, + data: { + removedMember: { + id: removedMember.id, + userId: removedMember.userId, + name: `${removedMember.user.firstName} ${removedMember.user.lastName}`, + removedAt: removedMember.deletedAt, + }, + team: { + id: team.id, + name: team.name, + }, + }, + }); + } catch (error) { + if (error.code === 'P2025') { + return res.status(404).json({ + success: false, + message: 'Team member not found', + }); + } + next(error); + } +}; + /** * @desc Update a team * @route /api/organization/:organizationId/department/:departmentId/team/:teamId diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 38f44c0..79f81b6 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -1867,6 +1867,149 @@ } } }, + "/api/organization/{organizationId}/department/{departmentId}/team/{teamId}/members/{memberId}": { + "delete": { + "tags": ["Team"], + "summary": "Remove member from team", + "description": "Soft delete a team member (marks as deleted but retains in database). Requires admin privileges, organization ownership, department management, or team leadership rights. Prevents removing the only team leader.", + "operationId": "removeTeamMember", + "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" + } + }, + { + "name": "memberId", + "in": "path", + "required": true, + "description": "ID of the team member to remove", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Team member removed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveTeamMemberResponse" + } + } + } + }, + "400": { + "description": "Bad request - missing parameters or attempting to remove the only leader", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "missingParams": { + "value": { + "success": false, + "message": "Organization ID, Department ID, Team ID, and Member ID are required" + } + }, + "lastLeader": { + "value": { + "success": false, + "message": "Cannot remove the only team leader. Please assign another leader first." + } + } + } + } + } + }, + "403": { + "description": "Forbidden - insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Organization, department, team or member 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" + } + }, + "memberNotFound": { + "value": { + "success": false, + "message": "Team member not found or already removed" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/organization/{organizationId}/department/{departmentId}/team/{teamId}": { "put": { "tags": ["Team"], @@ -4136,6 +4279,57 @@ } } } + }, + "RemoveTeamMemberResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Team member John Doe removed successfully" + }, + "data": { + "type": "object", + "properties": { + "removedMember": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "John Doe" + }, + "removedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "team": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } + } + } + } + } } } } diff --git a/src/routes/team.routes.js b/src/routes/team.routes.js index 902ab47..4323821 100644 --- a/src/routes/team.routes.js +++ b/src/routes/team.routes.js @@ -5,6 +5,7 @@ import { createTeam, deleteTeam, deleteTeamAvatar, + removeTeamMember, updateTeam, uploadTeamAvatar, } from '../controllers/team.controller.js'; @@ -49,4 +50,10 @@ router.delete( deleteTeam, ); +router.delete( + '/api/organization/:organizationId/department/:departmentId/team/:teamId/members/:memberId', + verifyAccessToken, + removeTeamMember, +); + export default router;