From 6ff136d63e58c056ee81752fcf389830a0f72d84 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 11:55:20 -0600 Subject: [PATCH] feat(api): add AdminModule with user and workspace management endpoints Implements MS21-API-001, MS21-API-002, MS21-API-005: Admin API endpoints for user management (list, invite, update, deactivate) and workspace management (create, update). All routes protected by AuthGuard + AdminGuard. Endpoints: - GET /api/admin/users (paginated) - POST /api/admin/users/invite - PATCH /api/admin/users/:id - DELETE /api/admin/users/:id (soft-delete via deactivatedAt) - POST /api/admin/workspaces - PATCH /api/admin/workspaces/:id Co-Authored-By: Claude Opus 4.6 --- apps/api/src/admin/admin.controller.spec.ts | 258 ++++++++++ apps/api/src/admin/admin.controller.ts | 64 +++ apps/api/src/admin/admin.module.ts | 13 + apps/api/src/admin/admin.service.spec.ts | 471 ++++++++++++++++++ apps/api/src/admin/admin.service.ts | 306 ++++++++++++ .../api/src/admin/dto/create-workspace.dto.ts | 15 + apps/api/src/admin/dto/invite-user.dto.ts | 20 + apps/api/src/admin/dto/manage-member.dto.ts | 15 + apps/api/src/admin/dto/query-users.dto.ts | 17 + apps/api/src/admin/dto/update-user.dto.ts | 27 + .../api/src/admin/dto/update-workspace.dto.ts | 13 + apps/api/src/admin/types/admin.types.ts | 49 ++ apps/api/src/app.module.ts | 2 + 13 files changed, 1270 insertions(+) create mode 100644 apps/api/src/admin/admin.controller.spec.ts create mode 100644 apps/api/src/admin/admin.controller.ts create mode 100644 apps/api/src/admin/admin.module.ts create mode 100644 apps/api/src/admin/admin.service.spec.ts create mode 100644 apps/api/src/admin/admin.service.ts create mode 100644 apps/api/src/admin/dto/create-workspace.dto.ts create mode 100644 apps/api/src/admin/dto/invite-user.dto.ts create mode 100644 apps/api/src/admin/dto/manage-member.dto.ts create mode 100644 apps/api/src/admin/dto/query-users.dto.ts create mode 100644 apps/api/src/admin/dto/update-user.dto.ts create mode 100644 apps/api/src/admin/dto/update-workspace.dto.ts create mode 100644 apps/api/src/admin/types/admin.types.ts diff --git a/apps/api/src/admin/admin.controller.spec.ts b/apps/api/src/admin/admin.controller.spec.ts new file mode 100644 index 0000000..66ca6e9 --- /dev/null +++ b/apps/api/src/admin/admin.controller.spec.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { AdminController } from "./admin.controller"; +import { AdminService } from "./admin.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { AdminGuard } from "../auth/guards/admin.guard"; +import { WorkspaceMemberRole } from "@prisma/client"; +import type { ExecutionContext } from "@nestjs/common"; + +describe("AdminController", () => { + let controller: AdminController; + let service: AdminService; + + const mockAdminService = { + listUsers: vi.fn(), + inviteUser: vi.fn(), + updateUser: vi.fn(), + deactivateUser: vi.fn(), + createWorkspace: vi.fn(), + updateWorkspace: vi.fn(), + }; + + const mockAuthGuard = { + canActivate: vi.fn((context: ExecutionContext) => { + const request = context.switchToHttp().getRequest(); + request.user = { + id: "550e8400-e29b-41d4-a716-446655440001", + email: "admin@example.com", + name: "Admin User", + }; + return true; + }), + }; + + const mockAdminGuard = { + canActivate: vi.fn(() => true), + }; + + const mockAdminId = "550e8400-e29b-41d4-a716-446655440001"; + const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003"; + + const mockAdminUser = { + id: mockAdminId, + email: "admin@example.com", + name: "Admin User", + }; + + const mockUserResponse = { + id: mockUserId, + name: "Test User", + email: "test@example.com", + emailVerified: false, + image: null, + createdAt: new Date("2026-01-01"), + deactivatedAt: null, + isLocalAuth: false, + invitedAt: null, + invitedBy: null, + workspaceMemberships: [], + }; + + const mockWorkspaceResponse = { + id: mockWorkspaceId, + name: "Test Workspace", + ownerId: mockAdminId, + settings: {}, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + memberCount: 1, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminController], + providers: [ + { + provide: AdminService, + useValue: mockAdminService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue(mockAuthGuard) + .overrideGuard(AdminGuard) + .useValue(mockAdminGuard) + .compile(); + + controller = module.get(AdminController); + service = module.get(AdminService); + + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + describe("listUsers", () => { + it("should return paginated users", async () => { + const paginatedResult = { + data: [mockUserResponse], + meta: { total: 1, page: 1, limit: 50, totalPages: 1 }, + }; + mockAdminService.listUsers.mockResolvedValue(paginatedResult); + + const result = await controller.listUsers({ page: 1, limit: 50 }); + + expect(result).toEqual(paginatedResult); + expect(service.listUsers).toHaveBeenCalledWith(1, 50); + }); + + it("should use default pagination", async () => { + const paginatedResult = { + data: [], + meta: { total: 0, page: 1, limit: 50, totalPages: 0 }, + }; + mockAdminService.listUsers.mockResolvedValue(paginatedResult); + + await controller.listUsers({}); + + expect(service.listUsers).toHaveBeenCalledWith(undefined, undefined); + }); + }); + + describe("inviteUser", () => { + it("should invite a user", async () => { + const inviteDto = { email: "new@example.com" }; + const invitationResponse = { + userId: "new-id", + invitationToken: "token", + email: "new@example.com", + invitedAt: new Date(), + }; + mockAdminService.inviteUser.mockResolvedValue(invitationResponse); + + const result = await controller.inviteUser(inviteDto, mockAdminUser); + + expect(result).toEqual(invitationResponse); + expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId); + }); + + it("should invite a user with workspace and role", async () => { + const inviteDto = { + email: "new@example.com", + workspaceId: mockWorkspaceId, + role: WorkspaceMemberRole.ADMIN, + }; + mockAdminService.inviteUser.mockResolvedValue({ + userId: "new-id", + invitationToken: "token", + email: "new@example.com", + invitedAt: new Date(), + }); + + await controller.inviteUser(inviteDto, mockAdminUser); + + expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId); + }); + }); + + describe("updateUser", () => { + it("should update a user", async () => { + const updateDto = { name: "Updated Name" }; + mockAdminService.updateUser.mockResolvedValue({ + ...mockUserResponse, + name: "Updated Name", + }); + + const result = await controller.updateUser(mockUserId, updateDto); + + expect(result.name).toBe("Updated Name"); + expect(service.updateUser).toHaveBeenCalledWith(mockUserId, updateDto); + }); + + it("should deactivate a user via update", async () => { + const deactivatedAt = "2026-02-28T00:00:00.000Z"; + const updateDto = { deactivatedAt }; + mockAdminService.updateUser.mockResolvedValue({ + ...mockUserResponse, + deactivatedAt: new Date(deactivatedAt), + }); + + const result = await controller.updateUser(mockUserId, updateDto); + + expect(result.deactivatedAt).toEqual(new Date(deactivatedAt)); + }); + }); + + describe("deactivateUser", () => { + it("should soft-delete a user", async () => { + mockAdminService.deactivateUser.mockResolvedValue({ + ...mockUserResponse, + deactivatedAt: new Date(), + }); + + const result = await controller.deactivateUser(mockUserId); + + expect(result.deactivatedAt).toBeDefined(); + expect(service.deactivateUser).toHaveBeenCalledWith(mockUserId); + }); + }); + + describe("createWorkspace", () => { + it("should create a workspace", async () => { + const createDto = { name: "New Workspace", ownerId: mockAdminId }; + mockAdminService.createWorkspace.mockResolvedValue(mockWorkspaceResponse); + + const result = await controller.createWorkspace(createDto); + + expect(result).toEqual(mockWorkspaceResponse); + expect(service.createWorkspace).toHaveBeenCalledWith(createDto); + }); + + it("should create workspace with settings", async () => { + const createDto = { + name: "New Workspace", + ownerId: mockAdminId, + settings: { feature: true }, + }; + mockAdminService.createWorkspace.mockResolvedValue({ + ...mockWorkspaceResponse, + settings: { feature: true }, + }); + + const result = await controller.createWorkspace(createDto); + + expect(result.settings).toEqual({ feature: true }); + }); + }); + + describe("updateWorkspace", () => { + it("should update a workspace", async () => { + const updateDto = { name: "Updated Workspace" }; + mockAdminService.updateWorkspace.mockResolvedValue({ + ...mockWorkspaceResponse, + name: "Updated Workspace", + }); + + const result = await controller.updateWorkspace(mockWorkspaceId, updateDto); + + expect(result.name).toBe("Updated Workspace"); + expect(service.updateWorkspace).toHaveBeenCalledWith(mockWorkspaceId, updateDto); + }); + + it("should update workspace settings", async () => { + const updateDto = { settings: { notifications: false } }; + mockAdminService.updateWorkspace.mockResolvedValue({ + ...mockWorkspaceResponse, + settings: { notifications: false }, + }); + + const result = await controller.updateWorkspace(mockWorkspaceId, updateDto); + + expect(result.settings).toEqual({ notifications: false }); + }); + }); +}); diff --git a/apps/api/src/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts new file mode 100644 index 0000000..d43c4cf --- /dev/null +++ b/apps/api/src/admin/admin.controller.ts @@ -0,0 +1,64 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from "@nestjs/common"; +import { AdminService } from "./admin.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { AdminGuard } from "../auth/guards/admin.guard"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import type { AuthUser } from "@mosaic/shared"; +import { InviteUserDto } from "./dto/invite-user.dto"; +import { UpdateUserDto } from "./dto/update-user.dto"; +import { CreateWorkspaceDto } from "./dto/create-workspace.dto"; +import { UpdateWorkspaceDto } from "./dto/update-workspace.dto"; +import { QueryUsersDto } from "./dto/query-users.dto"; + +@Controller("admin") +@UseGuards(AuthGuard, AdminGuard) +export class AdminController { + constructor(private readonly adminService: AdminService) {} + + @Get("users") + async listUsers(@Query() query: QueryUsersDto) { + return this.adminService.listUsers(query.page, query.limit); + } + + @Post("users/invite") + async inviteUser(@Body() dto: InviteUserDto, @CurrentUser() user: AuthUser) { + return this.adminService.inviteUser(dto, user.id); + } + + @Patch("users/:id") + async updateUser( + @Param("id", new ParseUUIDPipe({ version: "4" })) id: string, + @Body() dto: UpdateUserDto + ) { + return this.adminService.updateUser(id, dto); + } + + @Delete("users/:id") + async deactivateUser(@Param("id", new ParseUUIDPipe({ version: "4" })) id: string) { + return this.adminService.deactivateUser(id); + } + + @Post("workspaces") + async createWorkspace(@Body() dto: CreateWorkspaceDto) { + return this.adminService.createWorkspace(dto); + } + + @Patch("workspaces/:id") + async updateWorkspace( + @Param("id", new ParseUUIDPipe({ version: "4" })) id: string, + @Body() dto: UpdateWorkspaceDto + ) { + return this.adminService.updateWorkspace(id, dto); + } +} diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts new file mode 100644 index 0000000..4439962 --- /dev/null +++ b/apps/api/src/admin/admin.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { AdminController } from "./admin.controller"; +import { AdminService } from "./admin.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [AdminController], + providers: [AdminService], + exports: [AdminService], +}) +export class AdminModule {} diff --git a/apps/api/src/admin/admin.service.spec.ts b/apps/api/src/admin/admin.service.spec.ts new file mode 100644 index 0000000..2a9c6fd --- /dev/null +++ b/apps/api/src/admin/admin.service.spec.ts @@ -0,0 +1,471 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { AdminService } from "./admin.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common"; +import { WorkspaceMemberRole } from "@prisma/client"; + +describe("AdminService", () => { + let service: AdminService; + + const mockPrismaService = { + user: { + findMany: vi.fn(), + findUnique: vi.fn(), + count: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + workspace: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + workspaceMember: { + create: vi.fn(), + }, + $transaction: vi.fn(), + }; + + const mockAdminId = "550e8400-e29b-41d4-a716-446655440001"; + const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003"; + + const mockUser = { + id: mockUserId, + name: "Test User", + email: "test@example.com", + emailVerified: false, + image: null, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + deactivatedAt: null, + isLocalAuth: false, + passwordHash: null, + invitedBy: null, + invitationToken: null, + invitedAt: null, + authProviderId: null, + preferences: {}, + workspaceMemberships: [ + { + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.MEMBER, + joinedAt: new Date("2026-01-01"), + workspace: { id: mockWorkspaceId, name: "Test Workspace" }, + }, + ], + }; + + const mockWorkspace = { + id: mockWorkspaceId, + name: "Test Workspace", + ownerId: mockAdminId, + settings: {}, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + matrixRoomId: null, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(AdminService); + + vi.clearAllMocks(); + + mockPrismaService.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => { + return fn(mockPrismaService); + }); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("listUsers", () => { + it("should return paginated users with memberships", async () => { + mockPrismaService.user.findMany.mockResolvedValue([mockUser]); + mockPrismaService.user.count.mockResolvedValue(1); + + const result = await service.listUsers(1, 50); + + expect(result.data).toHaveLength(1); + expect(result.data[0]?.id).toBe(mockUserId); + expect(result.data[0]?.workspaceMemberships).toHaveLength(1); + expect(result.meta).toEqual({ + total: 1, + page: 1, + limit: 50, + totalPages: 1, + }); + }); + + it("should use default pagination when not provided", async () => { + mockPrismaService.user.findMany.mockResolvedValue([]); + mockPrismaService.user.count.mockResolvedValue(0); + + await service.listUsers(); + + expect(mockPrismaService.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 0, + take: 50, + }) + ); + }); + + it("should calculate pagination correctly", async () => { + mockPrismaService.user.findMany.mockResolvedValue([]); + mockPrismaService.user.count.mockResolvedValue(150); + + const result = await service.listUsers(3, 25); + + expect(mockPrismaService.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 50, + take: 25, + }) + ); + expect(result.meta.totalPages).toBe(6); + }); + }); + + describe("inviteUser", () => { + it("should create a user with invitation token", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + const createdUser = { + id: "new-user-id", + email: "new@example.com", + name: "new", + invitationToken: "some-token", + }; + mockPrismaService.user.create.mockResolvedValue(createdUser); + + const result = await service.inviteUser({ email: "new@example.com" }, mockAdminId); + + expect(result.email).toBe("new@example.com"); + expect(result.invitationToken).toBeDefined(); + expect(result.userId).toBe("new-user-id"); + expect(mockPrismaService.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + email: "new@example.com", + invitedBy: mockAdminId, + invitationToken: expect.any(String), + }), + }) + ); + }); + + it("should add user to workspace when workspaceId provided", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace); + const createdUser = { id: "new-user-id", email: "new@example.com", name: "new" }; + mockPrismaService.user.create.mockResolvedValue(createdUser); + + await service.inviteUser( + { + email: "new@example.com", + workspaceId: mockWorkspaceId, + role: WorkspaceMemberRole.ADMIN, + }, + mockAdminId + ); + + expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({ + data: { + workspaceId: mockWorkspaceId, + userId: "new-user-id", + role: WorkspaceMemberRole.ADMIN, + }, + }); + }); + + it("should throw ConflictException if email already exists", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + + await expect(service.inviteUser({ email: "test@example.com" }, mockAdminId)).rejects.toThrow( + ConflictException + ); + }); + + it("should throw NotFoundException if workspace does not exist", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.workspace.findUnique.mockResolvedValue(null); + + await expect( + service.inviteUser({ email: "new@example.com", workspaceId: "non-existent" }, mockAdminId) + ).rejects.toThrow(NotFoundException); + }); + + it("should use email prefix as default name", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + const createdUser = { id: "new-user-id", email: "jane.doe@example.com", name: "jane.doe" }; + mockPrismaService.user.create.mockResolvedValue(createdUser); + + await service.inviteUser({ email: "jane.doe@example.com" }, mockAdminId); + + expect(mockPrismaService.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: "jane.doe", + }), + }) + ); + }); + + it("should use provided name when given", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + const createdUser = { id: "new-user-id", email: "j@example.com", name: "Jane Doe" }; + mockPrismaService.user.create.mockResolvedValue(createdUser); + + await service.inviteUser({ email: "j@example.com", name: "Jane Doe" }, mockAdminId); + + expect(mockPrismaService.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: "Jane Doe", + }), + }) + ); + }); + }); + + describe("updateUser", () => { + it("should update user fields", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.user.update.mockResolvedValue({ + ...mockUser, + name: "Updated Name", + }); + + const result = await service.updateUser(mockUserId, { name: "Updated Name" }); + + expect(result.name).toBe("Updated Name"); + expect(mockPrismaService.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: mockUserId }, + data: { name: "Updated Name" }, + }) + ); + }); + + it("should set deactivatedAt when provided", async () => { + const deactivatedAt = "2026-02-28T00:00:00.000Z"; + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.user.update.mockResolvedValue({ + ...mockUser, + deactivatedAt: new Date(deactivatedAt), + }); + + const result = await service.updateUser(mockUserId, { deactivatedAt }); + + expect(result.deactivatedAt).toEqual(new Date(deactivatedAt)); + }); + + it("should clear deactivatedAt when set to null", async () => { + const deactivatedUser = { ...mockUser, deactivatedAt: new Date() }; + mockPrismaService.user.findUnique.mockResolvedValue(deactivatedUser); + mockPrismaService.user.update.mockResolvedValue({ + ...deactivatedUser, + deactivatedAt: null, + }); + + const result = await service.updateUser(mockUserId, { deactivatedAt: null }); + + expect(result.deactivatedAt).toBeNull(); + }); + + it("should throw NotFoundException if user does not exist", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + await expect(service.updateUser("non-existent", { name: "Test" })).rejects.toThrow( + NotFoundException + ); + }); + + it("should update emailVerified", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.user.update.mockResolvedValue({ + ...mockUser, + emailVerified: true, + }); + + const result = await service.updateUser(mockUserId, { emailVerified: true }); + + expect(result.emailVerified).toBe(true); + }); + + it("should update preferences", async () => { + const prefs = { theme: "dark" }; + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.user.update.mockResolvedValue({ + ...mockUser, + preferences: prefs, + }); + + await service.updateUser(mockUserId, { preferences: prefs }); + + expect(mockPrismaService.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ preferences: prefs }), + }) + ); + }); + }); + + describe("deactivateUser", () => { + it("should set deactivatedAt on the user", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.user.update.mockResolvedValue({ + ...mockUser, + deactivatedAt: new Date(), + }); + + const result = await service.deactivateUser(mockUserId); + + expect(result.deactivatedAt).toBeDefined(); + expect(mockPrismaService.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: mockUserId }, + data: { deactivatedAt: expect.any(Date) }, + }) + ); + }); + + it("should throw NotFoundException if user does not exist", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + await expect(service.deactivateUser("non-existent")).rejects.toThrow(NotFoundException); + }); + + it("should throw BadRequestException if user is already deactivated", async () => { + mockPrismaService.user.findUnique.mockResolvedValue({ + ...mockUser, + deactivatedAt: new Date(), + }); + + await expect(service.deactivateUser(mockUserId)).rejects.toThrow(BadRequestException); + }); + }); + + describe("createWorkspace", () => { + it("should create a workspace with owner membership", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.workspace.create.mockResolvedValue(mockWorkspace); + + const result = await service.createWorkspace({ + name: "New Workspace", + ownerId: mockAdminId, + }); + + expect(result.name).toBe("Test Workspace"); + expect(result.memberCount).toBe(1); + expect(mockPrismaService.workspace.create).toHaveBeenCalled(); + expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({ + data: { + workspaceId: mockWorkspace.id, + userId: mockAdminId, + role: WorkspaceMemberRole.OWNER, + }, + }); + }); + + it("should throw NotFoundException if owner does not exist", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + await expect( + service.createWorkspace({ name: "New Workspace", ownerId: "non-existent" }) + ).rejects.toThrow(NotFoundException); + }); + + it("should pass settings when provided", async () => { + const settings = { theme: "dark", features: ["chat"] }; + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.workspace.create.mockResolvedValue({ + ...mockWorkspace, + settings, + }); + + await service.createWorkspace({ + name: "New Workspace", + ownerId: mockAdminId, + settings, + }); + + expect(mockPrismaService.workspace.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ settings }), + }) + ); + }); + }); + + describe("updateWorkspace", () => { + it("should update workspace name", async () => { + mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace); + mockPrismaService.workspace.update.mockResolvedValue({ + ...mockWorkspace, + name: "Updated Workspace", + _count: { members: 3 }, + }); + + const result = await service.updateWorkspace(mockWorkspaceId, { + name: "Updated Workspace", + }); + + expect(result.name).toBe("Updated Workspace"); + expect(result.memberCount).toBe(3); + }); + + it("should update workspace settings", async () => { + const newSettings = { notifications: true }; + mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace); + mockPrismaService.workspace.update.mockResolvedValue({ + ...mockWorkspace, + settings: newSettings, + _count: { members: 1 }, + }); + + const result = await service.updateWorkspace(mockWorkspaceId, { + settings: newSettings, + }); + + expect(result.settings).toEqual(newSettings); + }); + + it("should throw NotFoundException if workspace does not exist", async () => { + mockPrismaService.workspace.findUnique.mockResolvedValue(null); + + await expect(service.updateWorkspace("non-existent", { name: "Test" })).rejects.toThrow( + NotFoundException + ); + }); + + it("should only update provided fields", async () => { + mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace); + mockPrismaService.workspace.update.mockResolvedValue({ + ...mockWorkspace, + _count: { members: 1 }, + }); + + await service.updateWorkspace(mockWorkspaceId, { name: "Only Name" }); + + expect(mockPrismaService.workspace.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { name: "Only Name" }, + }) + ); + }); + }); +}); diff --git a/apps/api/src/admin/admin.service.ts b/apps/api/src/admin/admin.service.ts new file mode 100644 index 0000000..e228ad6 --- /dev/null +++ b/apps/api/src/admin/admin.service.ts @@ -0,0 +1,306 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { Prisma, WorkspaceMemberRole } from "@prisma/client"; +import { randomUUID } from "node:crypto"; +import { PrismaService } from "../prisma/prisma.service"; +import type { InviteUserDto } from "./dto/invite-user.dto"; +import type { UpdateUserDto } from "./dto/update-user.dto"; +import type { CreateWorkspaceDto } from "./dto/create-workspace.dto"; +import type { + AdminUserResponse, + AdminWorkspaceResponse, + InvitationResponse, + PaginatedResponse, +} from "./types/admin.types"; + +@Injectable() +export class AdminService { + private readonly logger = new Logger(AdminService.name); + + constructor(private readonly prisma: PrismaService) {} + + async listUsers(page = 1, limit = 50): Promise> { + const skip = (page - 1) * limit; + + const [users, total] = await Promise.all([ + this.prisma.user.findMany({ + include: { + workspaceMemberships: { + include: { + workspace: { select: { id: true, name: true } }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + this.prisma.user.count(), + ]); + + return { + data: users.map((user) => ({ + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + createdAt: user.createdAt, + deactivatedAt: user.deactivatedAt, + isLocalAuth: user.isLocalAuth, + invitedAt: user.invitedAt, + invitedBy: user.invitedBy, + workspaceMemberships: user.workspaceMemberships.map((m) => ({ + workspaceId: m.workspaceId, + workspaceName: m.workspace.name, + role: m.role, + joinedAt: m.joinedAt, + })), + })), + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async inviteUser(dto: InviteUserDto, inviterId: string): Promise { + const existing = await this.prisma.user.findUnique({ + where: { email: dto.email }, + }); + + if (existing) { + throw new ConflictException(`User with email ${dto.email} already exists`); + } + + if (dto.workspaceId) { + const workspace = await this.prisma.workspace.findUnique({ + where: { id: dto.workspaceId }, + }); + if (!workspace) { + throw new NotFoundException(`Workspace ${dto.workspaceId} not found`); + } + } + + const invitationToken = randomUUID(); + const now = new Date(); + + const user = await this.prisma.$transaction(async (tx) => { + const created = await tx.user.create({ + data: { + email: dto.email, + name: dto.name ?? dto.email.split("@")[0] ?? dto.email, + emailVerified: false, + invitedBy: inviterId, + invitationToken, + invitedAt: now, + }, + }); + + if (dto.workspaceId) { + await tx.workspaceMember.create({ + data: { + workspaceId: dto.workspaceId, + userId: created.id, + role: dto.role ?? WorkspaceMemberRole.MEMBER, + }, + }); + } + + return created; + }); + + this.logger.log(`User invited: ${user.email} by ${inviterId}`); + + return { + userId: user.id, + invitationToken, + email: user.email, + invitedAt: now, + }; + } + + async updateUser(id: string, dto: UpdateUserDto): Promise { + const existing = await this.prisma.user.findUnique({ where: { id } }); + if (!existing) { + throw new NotFoundException(`User ${id} not found`); + } + + const data: Prisma.UserUpdateInput = {}; + + if (dto.name !== undefined) { + data.name = dto.name; + } + if (dto.emailVerified !== undefined) { + data.emailVerified = dto.emailVerified; + } + if (dto.preferences !== undefined) { + data.preferences = dto.preferences as Prisma.InputJsonValue; + } + if (dto.deactivatedAt !== undefined) { + data.deactivatedAt = dto.deactivatedAt ? new Date(dto.deactivatedAt) : null; + } + + const user = await this.prisma.user.update({ + where: { id }, + data, + include: { + workspaceMemberships: { + include: { + workspace: { select: { id: true, name: true } }, + }, + }, + }, + }); + + this.logger.log(`User updated: ${id}`); + + return { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + createdAt: user.createdAt, + deactivatedAt: user.deactivatedAt, + isLocalAuth: user.isLocalAuth, + invitedAt: user.invitedAt, + invitedBy: user.invitedBy, + workspaceMemberships: user.workspaceMemberships.map((m) => ({ + workspaceId: m.workspaceId, + workspaceName: m.workspace.name, + role: m.role, + joinedAt: m.joinedAt, + })), + }; + } + + async deactivateUser(id: string): Promise { + const existing = await this.prisma.user.findUnique({ where: { id } }); + if (!existing) { + throw new NotFoundException(`User ${id} not found`); + } + + if (existing.deactivatedAt) { + throw new BadRequestException(`User ${id} is already deactivated`); + } + + const user = await this.prisma.user.update({ + where: { id }, + data: { deactivatedAt: new Date() }, + include: { + workspaceMemberships: { + include: { + workspace: { select: { id: true, name: true } }, + }, + }, + }, + }); + + this.logger.log(`User deactivated: ${id}`); + + return { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + createdAt: user.createdAt, + deactivatedAt: user.deactivatedAt, + isLocalAuth: user.isLocalAuth, + invitedAt: user.invitedAt, + invitedBy: user.invitedBy, + workspaceMemberships: user.workspaceMemberships.map((m) => ({ + workspaceId: m.workspaceId, + workspaceName: m.workspace.name, + role: m.role, + joinedAt: m.joinedAt, + })), + }; + } + + async createWorkspace(dto: CreateWorkspaceDto): Promise { + const owner = await this.prisma.user.findUnique({ where: { id: dto.ownerId } }); + if (!owner) { + throw new NotFoundException(`User ${dto.ownerId} not found`); + } + + const workspace = await this.prisma.$transaction(async (tx) => { + const created = await tx.workspace.create({ + data: { + name: dto.name, + ownerId: dto.ownerId, + settings: dto.settings ? (dto.settings as Prisma.InputJsonValue) : {}, + }, + }); + + await tx.workspaceMember.create({ + data: { + workspaceId: created.id, + userId: dto.ownerId, + role: WorkspaceMemberRole.OWNER, + }, + }); + + return created; + }); + + this.logger.log(`Workspace created: ${workspace.id} with owner ${dto.ownerId}`); + + return { + id: workspace.id, + name: workspace.name, + ownerId: workspace.ownerId, + settings: workspace.settings as Record, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + memberCount: 1, + }; + } + + async updateWorkspace( + id: string, + dto: { name?: string; settings?: Record } + ): Promise { + const existing = await this.prisma.workspace.findUnique({ where: { id } }); + if (!existing) { + throw new NotFoundException(`Workspace ${id} not found`); + } + + const data: Prisma.WorkspaceUpdateInput = {}; + + if (dto.name !== undefined) { + data.name = dto.name; + } + if (dto.settings !== undefined) { + data.settings = dto.settings as Prisma.InputJsonValue; + } + + const workspace = await this.prisma.workspace.update({ + where: { id }, + data, + include: { + _count: { select: { members: true } }, + }, + }); + + this.logger.log(`Workspace updated: ${id}`); + + return { + id: workspace.id, + name: workspace.name, + ownerId: workspace.ownerId, + settings: workspace.settings as Record, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + memberCount: workspace._count.members, + }; + } +} diff --git a/apps/api/src/admin/dto/create-workspace.dto.ts b/apps/api/src/admin/dto/create-workspace.dto.ts new file mode 100644 index 0000000..97f5291 --- /dev/null +++ b/apps/api/src/admin/dto/create-workspace.dto.ts @@ -0,0 +1,15 @@ +import { IsObject, IsOptional, IsString, IsUUID, MaxLength, MinLength } from "class-validator"; + +export class CreateWorkspaceDto { + @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; + + @IsUUID("4", { message: "ownerId must be a valid UUID" }) + ownerId!: string; + + @IsOptional() + @IsObject({ message: "settings must be an object" }) + settings?: Record; +} diff --git a/apps/api/src/admin/dto/invite-user.dto.ts b/apps/api/src/admin/dto/invite-user.dto.ts new file mode 100644 index 0000000..c8a77ea --- /dev/null +++ b/apps/api/src/admin/dto/invite-user.dto.ts @@ -0,0 +1,20 @@ +import { WorkspaceMemberRole } from "@prisma/client"; +import { IsEmail, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from "class-validator"; + +export class InviteUserDto { + @IsEmail({}, { message: "email must be a valid email address" }) + email!: string; + + @IsOptional() + @IsString({ message: "name must be a string" }) + @MaxLength(255, { message: "name must not exceed 255 characters" }) + name?: string; + + @IsOptional() + @IsUUID("4", { message: "workspaceId must be a valid UUID" }) + workspaceId?: string; + + @IsOptional() + @IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" }) + role?: WorkspaceMemberRole; +} diff --git a/apps/api/src/admin/dto/manage-member.dto.ts b/apps/api/src/admin/dto/manage-member.dto.ts new file mode 100644 index 0000000..fa92861 --- /dev/null +++ b/apps/api/src/admin/dto/manage-member.dto.ts @@ -0,0 +1,15 @@ +import { WorkspaceMemberRole } from "@prisma/client"; +import { IsEnum, IsUUID } from "class-validator"; + +export class AddMemberDto { + @IsUUID("4", { message: "userId must be a valid UUID" }) + userId!: string; + + @IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" }) + role!: WorkspaceMemberRole; +} + +export class UpdateMemberRoleDto { + @IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" }) + role!: WorkspaceMemberRole; +} diff --git a/apps/api/src/admin/dto/query-users.dto.ts b/apps/api/src/admin/dto/query-users.dto.ts new file mode 100644 index 0000000..29c6d4e --- /dev/null +++ b/apps/api/src/admin/dto/query-users.dto.ts @@ -0,0 +1,17 @@ +import { IsInt, IsOptional, Max, Min } from "class-validator"; +import { Type } from "class-transformer"; + +export class QueryUsersDto { + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; +} diff --git a/apps/api/src/admin/dto/update-user.dto.ts b/apps/api/src/admin/dto/update-user.dto.ts new file mode 100644 index 0000000..92bd2a0 --- /dev/null +++ b/apps/api/src/admin/dto/update-user.dto.ts @@ -0,0 +1,27 @@ +import { + IsBoolean, + IsDateString, + IsObject, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; + +export class UpdateUserDto { + @IsOptional() + @IsString({ message: "name must be a string" }) + @MaxLength(255, { message: "name must not exceed 255 characters" }) + name?: string; + + @IsOptional() + @IsDateString({}, { message: "deactivatedAt must be a valid ISO 8601 date string" }) + deactivatedAt?: string | null; + + @IsOptional() + @IsBoolean({ message: "emailVerified must be a boolean" }) + emailVerified?: boolean; + + @IsOptional() + @IsObject({ message: "preferences must be an object" }) + preferences?: Record; +} diff --git a/apps/api/src/admin/dto/update-workspace.dto.ts b/apps/api/src/admin/dto/update-workspace.dto.ts new file mode 100644 index 0000000..60754ba --- /dev/null +++ b/apps/api/src/admin/dto/update-workspace.dto.ts @@ -0,0 +1,13 @@ +import { IsObject, IsOptional, IsString, MaxLength, MinLength } from "class-validator"; + +export class UpdateWorkspaceDto { + @IsOptional() + @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() + @IsObject({ message: "settings must be an object" }) + settings?: Record; +} diff --git a/apps/api/src/admin/types/admin.types.ts b/apps/api/src/admin/types/admin.types.ts new file mode 100644 index 0000000..6860a1d --- /dev/null +++ b/apps/api/src/admin/types/admin.types.ts @@ -0,0 +1,49 @@ +import type { WorkspaceMemberRole } from "@prisma/client"; + +export interface AdminUserResponse { + id: string; + name: string; + email: string; + emailVerified: boolean; + image: string | null; + createdAt: Date; + deactivatedAt: Date | null; + isLocalAuth: boolean; + invitedAt: Date | null; + invitedBy: string | null; + workspaceMemberships: WorkspaceMembershipResponse[]; +} + +export interface WorkspaceMembershipResponse { + workspaceId: string; + workspaceName: string; + role: WorkspaceMemberRole; + joinedAt: Date; +} + +export interface PaginatedResponse { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export interface InvitationResponse { + userId: string; + invitationToken: string; + email: string; + invitedAt: Date; +} + +export interface AdminWorkspaceResponse { + id: string; + name: string; + ownerId: string; + settings: Record; + createdAt: Date; + updatedAt: Date; + memberCount: number; +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 994d191..c1fc923 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -43,6 +43,7 @@ import { DashboardModule } from "./dashboard/dashboard.module"; 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 { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; @Module({ @@ -109,6 +110,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce TerminalModule, PersonalitiesModule, WorkspacesModule, + AdminModule, ], controllers: [AppController, CsrfController], providers: [