feat(api): add team management module with CRUD endpoints (#564)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #564.
This commit is contained in:
@@ -44,6 +44,7 @@ import { TerminalModule } from "./terminal/terminal.module";
|
|||||||
import { PersonalitiesModule } from "./personalities/personalities.module";
|
import { PersonalitiesModule } from "./personalities/personalities.module";
|
||||||
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
||||||
import { AdminModule } from "./admin/admin.module";
|
import { AdminModule } from "./admin/admin.module";
|
||||||
|
import { TeamsModule } from "./teams/teams.module";
|
||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -111,6 +112,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
PersonalitiesModule,
|
PersonalitiesModule,
|
||||||
WorkspacesModule,
|
WorkspacesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
TeamsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
13
apps/api/src/teams/dto/create-team.dto.ts
Normal file
13
apps/api/src/teams/dto/create-team.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
11
apps/api/src/teams/dto/manage-team-member.dto.ts
Normal file
11
apps/api/src/teams/dto/manage-team-member.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
150
apps/api/src/teams/teams.controller.spec.ts
Normal file
150
apps/api/src/teams/teams.controller.spec.ts
Normal file
@@ -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>(TeamsController);
|
||||||
|
service = module.get<TeamsService>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
51
apps/api/src/teams/teams.controller.ts
Normal file
51
apps/api/src/teams/teams.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/teams/teams.module.ts
Normal file
13
apps/api/src/teams/teams.module.ts
Normal file
@@ -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 {}
|
||||||
286
apps/api/src/teams/teams.service.spec.ts
Normal file
286
apps/api/src/teams/teams.service.spec.ts
Normal file
@@ -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>(TeamsService);
|
||||||
|
prisma = module.get<PrismaService>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
130
apps/api/src/teams/teams.service.ts
Normal file
130
apps/api/src/teams/teams.service.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user