From 0e6734bdae242c6088ccb171ac27ed87af35b9eb Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 18:24:09 +0000 Subject: [PATCH] feat(api): add team management module with CRUD endpoints (#564) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- apps/api/src/app.module.ts | 2 + apps/api/src/teams/dto/create-team.dto.ts | 13 + .../src/teams/dto/manage-team-member.dto.ts | 11 + apps/api/src/teams/teams.controller.spec.ts | 150 +++++++++ apps/api/src/teams/teams.controller.ts | 51 ++++ apps/api/src/teams/teams.module.ts | 13 + apps/api/src/teams/teams.service.spec.ts | 286 ++++++++++++++++++ apps/api/src/teams/teams.service.ts | 130 ++++++++ 8 files changed, 656 insertions(+) create mode 100644 apps/api/src/teams/dto/create-team.dto.ts create mode 100644 apps/api/src/teams/dto/manage-team-member.dto.ts create mode 100644 apps/api/src/teams/teams.controller.spec.ts create mode 100644 apps/api/src/teams/teams.controller.ts create mode 100644 apps/api/src/teams/teams.module.ts create mode 100644 apps/api/src/teams/teams.service.spec.ts create mode 100644 apps/api/src/teams/teams.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c1fc923..1ae7359 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -44,6 +44,7 @@ import { TerminalModule } from "./terminal/terminal.module"; import { PersonalitiesModule } from "./personalities/personalities.module"; import { WorkspacesModule } from "./workspaces/workspaces.module"; import { AdminModule } from "./admin/admin.module"; +import { TeamsModule } from "./teams/teams.module"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; @Module({ @@ -111,6 +112,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce PersonalitiesModule, WorkspacesModule, AdminModule, + TeamsModule, ], controllers: [AppController, CsrfController], providers: [ diff --git a/apps/api/src/teams/dto/create-team.dto.ts b/apps/api/src/teams/dto/create-team.dto.ts new file mode 100644 index 0000000..d32fb60 --- /dev/null +++ b/apps/api/src/teams/dto/create-team.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString, MaxLength, MinLength } from "class-validator"; + +export class CreateTeamDto { + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(255, { message: "name must not exceed 255 characters" }) + name!: string; + + @IsOptional() + @IsString({ message: "description must be a string" }) + @MaxLength(10000, { message: "description must not exceed 10000 characters" }) + description?: string; +} diff --git a/apps/api/src/teams/dto/manage-team-member.dto.ts b/apps/api/src/teams/dto/manage-team-member.dto.ts new file mode 100644 index 0000000..f0b8192 --- /dev/null +++ b/apps/api/src/teams/dto/manage-team-member.dto.ts @@ -0,0 +1,11 @@ +import { TeamMemberRole } from "@prisma/client"; +import { IsEnum, IsOptional, IsUUID } from "class-validator"; + +export class ManageTeamMemberDto { + @IsUUID("4", { message: "userId must be a valid UUID" }) + userId!: string; + + @IsOptional() + @IsEnum(TeamMemberRole, { message: "role must be a valid TeamMemberRole" }) + role?: TeamMemberRole; +} diff --git a/apps/api/src/teams/teams.controller.spec.ts b/apps/api/src/teams/teams.controller.spec.ts new file mode 100644 index 0000000..3cab617 --- /dev/null +++ b/apps/api/src/teams/teams.controller.spec.ts @@ -0,0 +1,150 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { TeamMemberRole } from "@prisma/client"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { PermissionGuard, WorkspaceGuard } from "../common/guards"; +import { TeamsController } from "./teams.controller"; +import { TeamsService } from "./teams.service"; + +describe("TeamsController", () => { + let controller: TeamsController; + let service: TeamsService; + + const mockTeamsService = { + create: vi.fn(), + findAll: vi.fn(), + addMember: vi.fn(), + removeMember: vi.fn(), + remove: vi.fn(), + }; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const mockTeamId = "550e8400-e29b-41d4-a716-446655440002"; + const mockUserId = "550e8400-e29b-41d4-a716-446655440003"; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TeamsController], + providers: [ + { + provide: TeamsService, + useValue: mockTeamsService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: vi.fn(() => true) }) + .overrideGuard(WorkspaceGuard) + .useValue({ canActivate: vi.fn(() => true) }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: vi.fn(() => true) }) + .compile(); + + controller = module.get(TeamsController); + service = module.get(TeamsService); + + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + describe("create", () => { + it("should create a team in a workspace", async () => { + const createDto = { + name: "Platform Team", + description: "Owns platform services", + }; + + const createdTeam = { + id: mockTeamId, + workspaceId: mockWorkspaceId, + name: createDto.name, + description: createDto.description, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockTeamsService.create.mockResolvedValue(createdTeam); + + const result = await controller.create(createDto, mockWorkspaceId); + + expect(result).toEqual(createdTeam); + expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto); + }); + }); + + describe("findAll", () => { + it("should list teams in a workspace", async () => { + const teams = [ + { + id: mockTeamId, + workspaceId: mockWorkspaceId, + name: "Platform Team", + description: "Owns platform services", + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + _count: { members: 2 }, + }, + ]; + + mockTeamsService.findAll.mockResolvedValue(teams); + + const result = await controller.findAll(mockWorkspaceId); + + expect(result).toEqual(teams); + expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId); + }); + }); + + describe("addMember", () => { + it("should add a member to a team", async () => { + const dto = { + userId: mockUserId, + role: TeamMemberRole.ADMIN, + }; + + const createdTeamMember = { + teamId: mockTeamId, + userId: mockUserId, + role: TeamMemberRole.ADMIN, + joinedAt: new Date(), + user: { + id: mockUserId, + name: "Test User", + email: "test@example.com", + }, + }; + + mockTeamsService.addMember.mockResolvedValue(createdTeamMember); + + const result = await controller.addMember(mockTeamId, dto, mockWorkspaceId); + + expect(result).toEqual(createdTeamMember); + expect(service.addMember).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId, dto); + }); + }); + + describe("removeMember", () => { + it("should remove a member from a team", async () => { + mockTeamsService.removeMember.mockResolvedValue(undefined); + + await controller.removeMember(mockTeamId, mockUserId, mockWorkspaceId); + + expect(service.removeMember).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId, mockUserId); + }); + }); + + describe("remove", () => { + it("should delete a team", async () => { + mockTeamsService.remove.mockResolvedValue(undefined); + + await controller.remove(mockTeamId, mockWorkspaceId); + + expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId); + }); + }); +}); diff --git a/apps/api/src/teams/teams.controller.ts b/apps/api/src/teams/teams.controller.ts new file mode 100644 index 0000000..13a11c2 --- /dev/null +++ b/apps/api/src/teams/teams.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { PermissionGuard, WorkspaceGuard } from "../common/guards"; +import { Permission, RequirePermission, Workspace } from "../common/decorators"; +import { CreateTeamDto } from "./dto/create-team.dto"; +import { ManageTeamMemberDto } from "./dto/manage-team-member.dto"; +import { TeamsService } from "./teams.service"; + +@Controller("workspaces/:workspaceId/teams") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class TeamsController { + constructor(private readonly teamsService: TeamsService) {} + + @Post() + @RequirePermission(Permission.WORKSPACE_ADMIN) + async create(@Body() createTeamDto: CreateTeamDto, @Workspace() workspaceId: string) { + return this.teamsService.create(workspaceId, createTeamDto); + } + + @Get() + @RequirePermission(Permission.WORKSPACE_ANY) + async findAll(@Workspace() workspaceId: string) { + return this.teamsService.findAll(workspaceId); + } + + @Post(":teamId/members") + @RequirePermission(Permission.WORKSPACE_ADMIN) + async addMember( + @Param("teamId") teamId: string, + @Body() dto: ManageTeamMemberDto, + @Workspace() workspaceId: string + ) { + return this.teamsService.addMember(workspaceId, teamId, dto); + } + + @Delete(":teamId/members/:userId") + @RequirePermission(Permission.WORKSPACE_ADMIN) + async removeMember( + @Param("teamId") teamId: string, + @Param("userId") userId: string, + @Workspace() workspaceId: string + ) { + return this.teamsService.removeMember(workspaceId, teamId, userId); + } + + @Delete(":teamId") + @RequirePermission(Permission.WORKSPACE_ADMIN) + async remove(@Param("teamId") teamId: string, @Workspace() workspaceId: string) { + return this.teamsService.remove(workspaceId, teamId); + } +} diff --git a/apps/api/src/teams/teams.module.ts b/apps/api/src/teams/teams.module.ts new file mode 100644 index 0000000..f006d98 --- /dev/null +++ b/apps/api/src/teams/teams.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; +import { PrismaModule } from "../prisma/prisma.module"; +import { TeamsController } from "./teams.controller"; +import { TeamsService } from "./teams.service"; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [TeamsController], + providers: [TeamsService], + exports: [TeamsService], +}) +export class TeamsModule {} diff --git a/apps/api/src/teams/teams.service.spec.ts b/apps/api/src/teams/teams.service.spec.ts new file mode 100644 index 0000000..bee7323 --- /dev/null +++ b/apps/api/src/teams/teams.service.spec.ts @@ -0,0 +1,286 @@ +import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { TeamMemberRole } from "@prisma/client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PrismaService } from "../prisma/prisma.service"; +import { TeamsService } from "./teams.service"; + +describe("TeamsService", () => { + let service: TeamsService; + let prisma: PrismaService; + + const mockPrismaService = { + team: { + create: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + deleteMany: vi.fn(), + }, + workspaceMember: { + findUnique: vi.fn(), + }, + teamMember: { + findUnique: vi.fn(), + create: vi.fn(), + deleteMany: vi.fn(), + }, + }; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const mockTeamId = "550e8400-e29b-41d4-a716-446655440002"; + const mockUserId = "550e8400-e29b-41d4-a716-446655440003"; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TeamsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(TeamsService); + prisma = module.get(PrismaService); + + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("create", () => { + it("should create a team", async () => { + const createDto = { + name: "Platform Team", + description: "Owns platform services", + }; + + const createdTeam = { + id: mockTeamId, + workspaceId: mockWorkspaceId, + name: createDto.name, + description: createDto.description, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.team.create.mockResolvedValue(createdTeam); + + const result = await service.create(mockWorkspaceId, createDto); + + expect(result).toEqual(createdTeam); + expect(prisma.team.create).toHaveBeenCalledWith({ + data: { + workspaceId: mockWorkspaceId, + name: createDto.name, + description: createDto.description, + }, + }); + }); + }); + + describe("findAll", () => { + it("should list teams for a workspace", async () => { + const teams = [ + { + id: mockTeamId, + workspaceId: mockWorkspaceId, + name: "Platform Team", + description: "Owns platform services", + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + _count: { members: 1 }, + }, + ]; + + mockPrismaService.team.findMany.mockResolvedValue(teams); + + const result = await service.findAll(mockWorkspaceId); + + expect(result).toEqual(teams); + expect(prisma.team.findMany).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + include: { + _count: { + select: { members: true }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + }); + }); + + describe("addMember", () => { + it("should add a workspace member to a team", async () => { + const dto = { + userId: mockUserId, + role: TeamMemberRole.ADMIN, + }; + + const createdTeamMember = { + teamId: mockTeamId, + userId: mockUserId, + role: TeamMemberRole.ADMIN, + joinedAt: new Date(), + user: { + id: mockUserId, + name: "Test User", + email: "test@example.com", + }, + }; + + mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId }); + mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId }); + mockPrismaService.teamMember.findUnique.mockResolvedValue(null); + mockPrismaService.teamMember.create.mockResolvedValue(createdTeamMember); + + const result = await service.addMember(mockWorkspaceId, mockTeamId, dto); + + expect(result).toEqual(createdTeamMember); + expect(prisma.team.findFirst).toHaveBeenCalledWith({ + where: { + id: mockTeamId, + workspaceId: mockWorkspaceId, + }, + select: { id: true }, + }); + expect(prisma.workspaceMember.findUnique).toHaveBeenCalledWith({ + where: { + workspaceId_userId: { + workspaceId: mockWorkspaceId, + userId: mockUserId, + }, + }, + select: { userId: true }, + }); + expect(prisma.teamMember.create).toHaveBeenCalledWith({ + data: { + teamId: mockTeamId, + userId: mockUserId, + role: TeamMemberRole.ADMIN, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + }); + + it("should use MEMBER role when role is omitted", async () => { + const dto = { userId: mockUserId }; + + mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId }); + mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId }); + mockPrismaService.teamMember.findUnique.mockResolvedValue(null); + mockPrismaService.teamMember.create.mockResolvedValue({ + teamId: mockTeamId, + userId: mockUserId, + role: TeamMemberRole.MEMBER, + joinedAt: new Date(), + }); + + await service.addMember(mockWorkspaceId, mockTeamId, dto); + + expect(prisma.teamMember.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + role: TeamMemberRole.MEMBER, + }), + }) + ); + }); + + it("should throw when team does not belong to workspace", async () => { + mockPrismaService.team.findFirst.mockResolvedValue(null); + + await expect( + service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId }) + ).rejects.toThrow(NotFoundException); + expect(prisma.workspaceMember.findUnique).not.toHaveBeenCalled(); + }); + + it("should throw when user is not a workspace member", async () => { + mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId }); + mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null); + + await expect( + service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId }) + ).rejects.toThrow(BadRequestException); + }); + + it("should throw when user is already in the team", async () => { + mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId }); + mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId }); + mockPrismaService.teamMember.findUnique.mockResolvedValue({ userId: mockUserId }); + + await expect( + service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId }) + ).rejects.toThrow(ConflictException); + }); + }); + + describe("removeMember", () => { + it("should remove a member from a team", async () => { + mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId }); + mockPrismaService.teamMember.deleteMany.mockResolvedValue({ count: 1 }); + + await service.removeMember(mockWorkspaceId, mockTeamId, mockUserId); + + expect(prisma.teamMember.deleteMany).toHaveBeenCalledWith({ + where: { + teamId: mockTeamId, + userId: mockUserId, + }, + }); + }); + + it("should throw when team does not belong to workspace", async () => { + mockPrismaService.team.findFirst.mockResolvedValue(null); + + await expect(service.removeMember(mockWorkspaceId, mockTeamId, mockUserId)).rejects.toThrow( + NotFoundException + ); + expect(prisma.teamMember.deleteMany).not.toHaveBeenCalled(); + }); + + it("should throw when user is not in the team", async () => { + mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId }); + mockPrismaService.teamMember.deleteMany.mockResolvedValue({ count: 0 }); + + await expect(service.removeMember(mockWorkspaceId, mockTeamId, mockUserId)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe("remove", () => { + it("should delete a team", async () => { + mockPrismaService.team.deleteMany.mockResolvedValue({ count: 1 }); + + await service.remove(mockWorkspaceId, mockTeamId); + + expect(prisma.team.deleteMany).toHaveBeenCalledWith({ + where: { + id: mockTeamId, + workspaceId: mockWorkspaceId, + }, + }); + }); + + it("should throw when team is not found", async () => { + mockPrismaService.team.deleteMany.mockResolvedValue({ count: 0 }); + + await expect(service.remove(mockWorkspaceId, mockTeamId)).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/teams/teams.service.ts b/apps/api/src/teams/teams.service.ts new file mode 100644 index 0000000..1e0370e --- /dev/null +++ b/apps/api/src/teams/teams.service.ts @@ -0,0 +1,130 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { TeamMemberRole } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { CreateTeamDto } from "./dto/create-team.dto"; +import { ManageTeamMemberDto } from "./dto/manage-team-member.dto"; + +@Injectable() +export class TeamsService { + constructor(private readonly prisma: PrismaService) {} + + async create(workspaceId: string, createTeamDto: CreateTeamDto) { + return this.prisma.team.create({ + data: { + workspaceId, + name: createTeamDto.name, + description: createTeamDto.description ?? null, + }, + }); + } + + async findAll(workspaceId: string) { + return this.prisma.team.findMany({ + where: { workspaceId }, + include: { + _count: { + select: { members: true }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + } + + async addMember(workspaceId: string, teamId: string, dto: ManageTeamMemberDto) { + await this.ensureTeamInWorkspace(workspaceId, teamId); + + const workspaceMember = await this.prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId: dto.userId, + }, + }, + select: { userId: true }, + }); + + if (!workspaceMember) { + throw new BadRequestException( + `User ${dto.userId} must be a workspace member before being added to a team` + ); + } + + const existingTeamMember = await this.prisma.teamMember.findUnique({ + where: { + teamId_userId: { + teamId, + userId: dto.userId, + }, + }, + select: { userId: true }, + }); + + if (existingTeamMember) { + throw new ConflictException(`User ${dto.userId} is already a member of team ${teamId}`); + } + + return this.prisma.teamMember.create({ + data: { + teamId, + userId: dto.userId, + role: dto.role ?? TeamMemberRole.MEMBER, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + } + + async removeMember(workspaceId: string, teamId: string, userId: string): Promise { + await this.ensureTeamInWorkspace(workspaceId, teamId); + + const result = await this.prisma.teamMember.deleteMany({ + where: { + teamId, + userId, + }, + }); + + if (result.count === 0) { + throw new NotFoundException(`User ${userId} is not a member of team ${teamId}`); + } + } + + async remove(workspaceId: string, teamId: string): Promise { + const result = await this.prisma.team.deleteMany({ + where: { + id: teamId, + workspaceId, + }, + }); + + if (result.count === 0) { + throw new NotFoundException(`Team with ID ${teamId} not found`); + } + } + + private async ensureTeamInWorkspace(workspaceId: string, teamId: string): Promise { + const team = await this.prisma.team.findFirst({ + where: { + id: teamId, + workspaceId, + }, + select: { id: true }, + }); + + if (!team) { + throw new NotFoundException(`Team with ID ${teamId} not found`); + } + } +}