Compare commits
1 Commits
feat/ms21-
...
2d7fb285c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d7fb285c3 |
@@ -44,7 +44,6 @@ 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({
|
||||||
@@ -112,7 +111,6 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
PersonalitiesModule,
|
PersonalitiesModule,
|
||||||
WorkspacesModule,
|
WorkspacesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
TeamsModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ describe("sanitizeForLogging", () => {
|
|||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
expect(result.password).toBe("[REDACTED]");
|
expect(result.password).toBe("[REDACTED]");
|
||||||
expect(duration).toBeLessThan(500); // Should complete in under 500ms
|
expect(duration).toBeLessThan(100); // Should complete in under 100ms
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ describe("CoordinatorIntegrationController - Rate Limiting", () => {
|
|||||||
.set("X-API-Key", "test-coordinator-key");
|
.set("X-API-Key", "test-coordinator-key");
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||||
}, 30000);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Per-API-Key Rate Limiting", () => {
|
describe("Per-API-Key Rate Limiting", () => {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
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`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -109,6 +109,7 @@ export default function WorkspaceDetailPage({
|
|||||||
// TODO: Replace with actual role check when API is implemented
|
// TODO: Replace with actual role check when API is implemented
|
||||||
// Currently hardcoded to OWNER in mock data (line 89)
|
// Currently hardcoded to OWNER in mock data (line 89)
|
||||||
const canInvite =
|
const canInvite =
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
currentUserRole === WorkspaceMemberRole.OWNER || currentUserRole === WorkspaceMemberRole.ADMIN;
|
currentUserRole === WorkspaceMemberRole.OWNER || currentUserRole === WorkspaceMemberRole.ADMIN;
|
||||||
|
|
||||||
const handleUpdateWorkspace = async (name: string): Promise<void> => {
|
const handleUpdateWorkspace = async (name: string): Promise<void> => {
|
||||||
|
|||||||
@@ -1,333 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, type ReactElement, type SyntheticEvent } from "react";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import type { InviteUserDto, WorkspaceMemberRole } from "@/lib/api/admin";
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Types
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface WorkspaceOption {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InviteUserDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSubmit: (data: InviteUserDto) => Promise<void>;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
workspaces: WorkspaceOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Validation
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
|
|
||||||
function validateEmail(value: string): string | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return "Email is required.";
|
|
||||||
if (!EMAIL_REGEX.test(trimmed)) return "Please enter a valid email address.";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Component
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
export function InviteUserDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSubmit,
|
|
||||||
isSubmitting,
|
|
||||||
workspaces,
|
|
||||||
}: InviteUserDialogProps): ReactElement | null {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [workspaceId, setWorkspaceId] = useState("");
|
|
||||||
const [role, setRole] = useState<WorkspaceMemberRole>("MEMBER");
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function resetForm(): void {
|
|
||||||
setEmail("");
|
|
||||||
setName("");
|
|
||||||
setWorkspaceId("");
|
|
||||||
setRole("MEMBER");
|
|
||||||
setFormError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
const emailError = validateEmail(email);
|
|
||||||
if (emailError) {
|
|
||||||
setFormError(emailError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dto: InviteUserDto = {
|
|
||||||
email: email.trim(),
|
|
||||||
role,
|
|
||||||
};
|
|
||||||
const trimmedName = name.trim();
|
|
||||||
if (trimmedName) dto.name = trimmedName;
|
|
||||||
if (workspaceId) dto.workspaceId = workspaceId;
|
|
||||||
|
|
||||||
await onSubmit(dto);
|
|
||||||
resetForm();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setFormError(err instanceof Error ? err.message : "Failed to send invitation.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="invite-user-title"
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 50,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
data-testid="invite-dialog-backdrop"
|
|
||||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)" }}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isSubmitting) {
|
|
||||||
resetForm();
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dialog */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
background: "var(--surface, #fff)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
border: "1px solid var(--border, #e5e7eb)",
|
|
||||||
padding: 24,
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 480,
|
|
||||||
zIndex: 1,
|
|
||||||
maxHeight: "90vh",
|
|
||||||
overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
id="invite-user-title"
|
|
||||||
style={{
|
|
||||||
fontSize: "1.125rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text, #111)",
|
|
||||||
margin: "0 0 8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Invite User
|
|
||||||
</h2>
|
|
||||||
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
|
|
||||||
Send an invitation to join the platform.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
void handleSubmit(e);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Email */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="invite-email"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2, #374151)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Email <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="invite-email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setEmail(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="user@example.com"
|
|
||||||
maxLength={254}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="invite-name"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2, #374151)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="invite-name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setName(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="Full name (optional)"
|
|
||||||
maxLength={255}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Workspace */}
|
|
||||||
{workspaces.length > 0 && (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="invite-workspace"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2, #374151)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Workspace
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={workspaceId}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setWorkspaceId(v);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="invite-workspace">
|
|
||||||
<SelectValue placeholder="Select a workspace (optional)" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{workspaces.map((ws) => (
|
|
||||||
<SelectItem key={ws.id} value={ws.id}>
|
|
||||||
{ws.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Role */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="invite-role"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2, #374151)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Role
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={role}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setRole(v as WorkspaceMemberRole);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="invite-role">
|
|
||||||
<SelectValue placeholder="Select role" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="MEMBER">Member</SelectItem>
|
|
||||||
<SelectItem value="ADMIN">Admin</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form error */}
|
|
||||||
{formError !== null && (
|
|
||||||
<p
|
|
||||||
role="alert"
|
|
||||||
style={{
|
|
||||||
color: "var(--danger, #ef4444)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
margin: "0 0 12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 8 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
resetForm();
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px solid var(--border, #d1d5db)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "var(--text-2, #374151)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || !email.trim()}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "var(--primary, #111827)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: isSubmitting || !email.trim() ? "not-allowed" : "pointer",
|
|
||||||
opacity: isSubmitting || !email.trim() ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Sending..." : "Send Invitation"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
||||||
import { EntryStatus } from "@mosaic/shared";
|
import { EntryStatus } from "@mosaic/shared";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
|
||||||
"use client";
|
"use client";
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ describe("TaskItem", (): void => {
|
|||||||
dueDate: null,
|
dueDate: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<TaskItem task={taskWithoutDueDate} />);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
render(<TaskItem task={taskWithoutDueDate as any} />);
|
||||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { Task } from "@mosaic/shared";
|
import type { Task } from "@mosaic/shared";
|
||||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin API Client
|
|
||||||
* Handles admin-scoped user and workspace management requests.
|
|
||||||
* These endpoints require AdminGuard (OWNER/ADMIN role).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "./client";
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Response Types (mirrors apps/api/src/admin/types/admin.types.ts)
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
export type WorkspaceMemberRole = "OWNER" | "ADMIN" | "MEMBER" | "GUEST";
|
|
||||||
|
|
||||||
export interface WorkspaceMembershipResponse {
|
|
||||||
workspaceId: string;
|
|
||||||
workspaceName: string;
|
|
||||||
role: WorkspaceMemberRole;
|
|
||||||
joinedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUserResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
emailVerified: boolean;
|
|
||||||
image: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
deactivatedAt: string | null;
|
|
||||||
isLocalAuth: boolean;
|
|
||||||
invitedAt: string | null;
|
|
||||||
invitedBy: string | null;
|
|
||||||
workspaceMemberships: WorkspaceMembershipResponse[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedAdminResponse<T> {
|
|
||||||
data: T[];
|
|
||||||
meta: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
totalPages: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InvitationResponse {
|
|
||||||
userId: string;
|
|
||||||
invitationToken: string;
|
|
||||||
email: string;
|
|
||||||
invitedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Request DTOs
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
export interface InviteUserDto {
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
workspaceId?: string;
|
|
||||||
role?: WorkspaceMemberRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateUserDto {
|
|
||||||
name?: string;
|
|
||||||
deactivatedAt?: string | null;
|
|
||||||
emailVerified?: boolean;
|
|
||||||
preferences?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
API Functions
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
export async function fetchAdminUsers(
|
|
||||||
page = 1,
|
|
||||||
limit = 50
|
|
||||||
): Promise<PaginatedAdminResponse<AdminUserResponse>> {
|
|
||||||
return apiGet<PaginatedAdminResponse<AdminUserResponse>>(
|
|
||||||
`/api/admin/users?page=${String(page)}&limit=${String(limit)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function inviteUser(dto: InviteUserDto): Promise<InvitationResponse> {
|
|
||||||
return apiPost<InvitationResponse>("/api/admin/users/invite", dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateUser(
|
|
||||||
userId: string,
|
|
||||||
dto: UpdateUserDto
|
|
||||||
): Promise<AdminUserResponse> {
|
|
||||||
return apiPatch<AdminUserResponse>(`/api/admin/users/${userId}`, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deactivateUser(userId: string): Promise<void> {
|
|
||||||
await apiDelete(`/api/admin/users/${userId}`);
|
|
||||||
}
|
|
||||||
@@ -109,7 +109,7 @@ export function useLayout(): UseLayoutReturn {
|
|||||||
if (stored) {
|
if (stored) {
|
||||||
const emptyFallback: Record<string, LayoutConfig> = {};
|
const emptyFallback: Record<string, LayoutConfig> = {};
|
||||||
const parsed = safeJsonParse(stored, isLayoutConfigRecord, emptyFallback);
|
const parsed = safeJsonParse(stored, isLayoutConfigRecord, emptyFallback);
|
||||||
const parsedLayouts = parsed;
|
const parsedLayouts = parsed as Record<string, LayoutConfig>;
|
||||||
if (Object.keys(parsedLayouts).length > 0) {
|
if (Object.keys(parsedLayouts).length > 0) {
|
||||||
setLayouts(parsedLayouts);
|
setLayouts(parsedLayouts);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,36 +3,35 @@
|
|||||||
> Single-writer: orchestrator (Jarvis/OpenClaw) only. Workers read but never modify.
|
> Single-writer: orchestrator (Jarvis/OpenClaw) only. Workers read but never modify.
|
||||||
|
|
||||||
| id | status | milestone | description | pr | agent | notes |
|
| id | status | milestone | description | pr | agent | notes |
|
||||||
|----|--------|-----------|-------------|----|-------|-------|
|
| ------------- | ----------- | --------- | ------------------------------------------------------------------------------------------------------------------- | --- | ------------ | ------------------------------- |
|
||||||
| MS21-PLAN-001 | done | phase-1 | Write PRD, init mission, populate TASKS.md | #552 | orchestrator | CI: #552 green |
|
| MS21-PLAN-001 | done | phase-1 | Write PRD, init mission, populate TASKS.md | — | orchestrator | PRD at docs/PRD-MS21.md |
|
||||||
| MS21-DB-001 | done | phase-1 | Prisma migration: add user fields | #553 | claude-worker-1 | CI: #684 green |
|
| MS21-DB-001 | not-started | phase-1 | Prisma migration: add deactivatedAt, isLocalAuth, passwordHash, invitedBy, invitationToken, invitedAt to User model | — | — | Schema changes for auth + admin |
|
||||||
| MS21-API-001 | done | phase-1 | AdminModule with user/workspace admin endpoints | #555 | claude-worker-2 | CI: #689 green |
|
| MS21-API-001 | not-started | phase-1 | AdminModule: admin.module.ts, admin.service.ts, admin.controller.ts with AdminGuard | — | — | Full CRUD for user management |
|
||||||
| MS21-API-002 | done | phase-1 | Admin user endpoints (list, invite, update, deactivate) | #555 | claude-worker-2 | Combined with API-001 |
|
| MS21-API-002 | not-started | phase-1 | Admin user endpoints: GET /admin/users, POST /admin/users/invite, PATCH /admin/users/:id, DELETE /admin/users/:id | — | — | Requires MS21-DB-001 |
|
||||||
| MS21-API-003 | done | phase-1 | Workspace member management endpoints | #556 | codex-worker-1 | CI: #700 green |
|
| MS21-API-003 | not-started | phase-1 | Workspace member management: POST/PATCH/DELETE /workspaces/:id/members endpoints | — | — | Role hierarchy enforcement |
|
||||||
| MS21-API-004 | done | phase-1 | Team management module | #564 | codex-worker-2 | CI: #707 green |
|
| MS21-API-004 | not-started | phase-1 | Team management: POST /workspaces/:id/teams, team member CRUD | — | — | Extends existing Team model |
|
||||||
| MS21-API-005 | done | phase-1 | Admin workspace endpoints | #555 | claude-worker-2 | Combined with API-001 |
|
| MS21-API-005 | not-started | phase-1 | Admin workspace endpoints: POST/PATCH /admin/workspaces with owner assignment | — | — | |
|
||||||
| MS21-TEST-001 | done | phase-1 | Unit tests for AdminService and AdminController | #555 | claude-worker-2 | 26 tests included |
|
| MS21-TEST-001 | not-started | phase-1 | Unit tests for AdminService and AdminController (spec files) | — | — | Minimum coverage: 85% |
|
||||||
| MS21-AUTH-001 | done | phase-2 | LocalAuthModule: break-glass auth | #559 | claude-worker-3 | CI: #691 green |
|
| MS21-AUTH-001 | not-started | phase-2 | LocalAuthModule: local-auth.controller.ts, local-auth.service.ts | — | — | bcrypt password hashing |
|
||||||
| MS21-AUTH-002 | done | phase-2 | Break-glass setup endpoint | #559 | claude-worker-3 | Combined with AUTH-001 |
|
| MS21-AUTH-002 | not-started | phase-2 | Break-glass setup endpoint: /api/auth/local/setup with BREAKGLASS_SETUP_TOKEN validation | — | — | First-time admin creation |
|
||||||
| MS21-AUTH-003 | done | phase-2 | Break-glass login endpoint | #559 | claude-worker-3 | Combined with AUTH-001 |
|
| MS21-AUTH-003 | not-started | phase-2 | Break-glass login endpoint: /api/auth/local/login with session creation | — | — | BetterAuth session compat |
|
||||||
| MS21-AUTH-004 | not-started | phase-2 | Deactivation session invalidation | — | — | Deferred |
|
| MS21-AUTH-004 | not-started | phase-2 | Deactivation session invalidation: deactivating user kills all active sessions | — | — | Security requirement |
|
||||||
| MS21-TEST-002 | done | phase-2 | Unit tests for LocalAuth | #559 | claude-worker-3 | 27 tests included |
|
| MS21-TEST-002 | not-started | phase-2 | Unit tests for LocalAuthService and LocalAuthController | — | — | |
|
||||||
| MS21-MIG-001 | done | phase-3 | Migration script: scripts/migrate-brain.ts | #554 | codex-worker-1 | CI: #688 (test flaky, code clean) |
|
| MS21-MIG-001 | not-started | phase-3 | Migration script: scripts/migrate-brain.ts — read jarvis-brain data files | — | — | v2.0 format parsing |
|
||||||
| MS21-MIG-002 | done | phase-3 | Migration mapping: status/priority/domain mapping | #554 | codex-worker-1 | Included in MIG-001 |
|
| MS21-MIG-002 | not-started | phase-3 | Migration mapping: status/priority/domain mapping + metadata preservation | — | — | See PRD field mapping |
|
||||||
| MS21-MIG-003 | not-started | phase-3 | Migration execution: run on production database | — | — | Needs deploy |
|
| MS21-MIG-003 | not-started | phase-3 | Migration execution: dry-run + apply modes, idempotent, activity logging | — | — | |
|
||||||
| MS21-MIG-004 | not-started | phase-3 | Import API endpoints | — | — | |
|
| MS21-MIG-004 | not-started | phase-3 | Import API endpoints: POST /api/import/tasks, POST /api/import/projects | — | — | For future bulk imports |
|
||||||
| MS21-TEST-003 | not-started | phase-3 | Migration script tests | — | — | |
|
| MS21-TEST-003 | not-started | phase-3 | Migration script tests: validate dry-run output, mapping accuracy | — | — | |
|
||||||
| MS21-UI-001 | not-started | phase-4 | Settings/users page | — | — | |
|
| MS21-UI-001 | not-started | phase-4 | Settings/users page: user management table with search, sort, filter | — | — | |
|
||||||
| MS21-UI-002 | not-started | phase-4 | User detail/edit and invite dialogs | — | — | |
|
| MS21-UI-002 | not-started | phase-4 | User detail/edit dialog and invite user dialog | — | — | |
|
||||||
| MS21-UI-003 | not-started | phase-4 | Settings/workspaces page (wire to real API) | — | — | Mock data exists |
|
| MS21-UI-003 | not-started | phase-4 | Settings/workspaces page: workspace list, member counts, detail view | — | — | |
|
||||||
| MS21-UI-004 | not-started | phase-4 | Workspace member management UI | — | — | Components exist |
|
| MS21-UI-004 | not-started | phase-4 | Workspace member management: add/remove dialog with role picker | — | — | |
|
||||||
| MS21-UI-005 | not-started | phase-4 | Settings/teams page | — | — | |
|
| MS21-UI-005 | not-started | phase-4 | Settings/teams page: team list, create dialog, member management | — | — | |
|
||||||
| MS21-TEST-004 | not-started | phase-4 | Frontend component tests | — | — | |
|
| MS21-TEST-004 | not-started | phase-4 | Frontend component tests for admin pages | — | — | |
|
||||||
| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation role gating | — | — | |
|
| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation: show/hide admin items based on user role | — | — | |
|
||||||
| MS21-RBAC-002 | not-started | phase-5 | Settings page access restriction | — | — | |
|
| MS21-RBAC-002 | not-started | phase-5 | Settings pages: restrict access to admin-only routes | — | — | |
|
||||||
| MS21-RBAC-003 | not-started | phase-5 | Action button permission gating | — | — | |
|
| MS21-RBAC-003 | not-started | phase-5 | Action buttons: disable/hide based on permission level | — | — | |
|
||||||
| MS21-RBAC-004 | not-started | phase-5 | User profile role display | — | — | |
|
| MS21-RBAC-004 | not-started | phase-5 | User profile: show current role and workspace memberships | — | — | |
|
||||||
| MS21-VER-001 | not-started | phase-6 | Full quality gate pass | — | — | |
|
| MS21-VER-001 | not-started | phase-6 | Full quality gate pass: pnpm lint && pnpm build && pnpm test | — | — | All 4772+ tests + new |
|
||||||
| MS21-VER-002 | not-started | phase-6 | Deploy and smoke test | — | — | |
|
| MS21-VER-002 | not-started | phase-6 | Deploy to mosaic.woltje.com, smoke test all pages | — | — | |
|
||||||
| MS21-VER-003 | not-started | phase-6 | Tag v0.0.21 | — | — | |
|
| MS21-VER-003 | not-started | phase-6 | Tag v0.0.21, update PRD status to complete | — | — | |
|
||||||
| MS21-FIX-001 | done | phase-1 | Fix flaky CI tests (rate limit timeout + log sanitizer) | #562 | codex-worker-3 | CI: #705 green |
|
|
||||||
|
|||||||
Reference in New Issue
Block a user