feat(api): add AdminModule with user and workspace management endpoints
All checks were successful
ci/woodpecker/push/api Pipeline was successful
All checks were successful
ci/woodpecker/push/api Pipeline was successful
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 <noreply@anthropic.com>
This commit is contained in:
258
apps/api/src/admin/admin.controller.spec.ts
Normal file
258
apps/api/src/admin/admin.controller.spec.ts
Normal file
@@ -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>(AdminController);
|
||||
service = module.get<AdminService>(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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
64
apps/api/src/admin/admin.controller.ts
Normal file
64
apps/api/src/admin/admin.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/admin/admin.module.ts
Normal file
13
apps/api/src/admin/admin.module.ts
Normal file
@@ -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 {}
|
||||
471
apps/api/src/admin/admin.service.spec.ts
Normal file
471
apps/api/src/admin/admin.service.spec.ts
Normal file
@@ -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>(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" },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
306
apps/api/src/admin/admin.service.ts
Normal file
306
apps/api/src/admin/admin.service.ts
Normal file
@@ -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<PaginatedResponse<AdminUserResponse>> {
|
||||
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<InvitationResponse> {
|
||||
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<AdminUserResponse> {
|
||||
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<AdminUserResponse> {
|
||||
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<AdminWorkspaceResponse> {
|
||||
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<string, unknown>,
|
||||
createdAt: workspace.createdAt,
|
||||
updatedAt: workspace.updatedAt,
|
||||
memberCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async updateWorkspace(
|
||||
id: string,
|
||||
dto: { name?: string; settings?: Record<string, unknown> }
|
||||
): Promise<AdminWorkspaceResponse> {
|
||||
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<string, unknown>,
|
||||
createdAt: workspace.createdAt,
|
||||
updatedAt: workspace.updatedAt,
|
||||
memberCount: workspace._count.members,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
apps/api/src/admin/dto/create-workspace.dto.ts
Normal file
15
apps/api/src/admin/dto/create-workspace.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
20
apps/api/src/admin/dto/invite-user.dto.ts
Normal file
20
apps/api/src/admin/dto/invite-user.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
15
apps/api/src/admin/dto/manage-member.dto.ts
Normal file
15
apps/api/src/admin/dto/manage-member.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
17
apps/api/src/admin/dto/query-users.dto.ts
Normal file
17
apps/api/src/admin/dto/query-users.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
27
apps/api/src/admin/dto/update-user.dto.ts
Normal file
27
apps/api/src/admin/dto/update-user.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
13
apps/api/src/admin/dto/update-workspace.dto.ts
Normal file
13
apps/api/src/admin/dto/update-workspace.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
49
apps/api/src/admin/types/admin.types.ts
Normal file
49
apps/api/src/admin/types/admin.types.ts
Normal file
@@ -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<T> {
|
||||
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<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
memberCount: number;
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user