admin area

This commit is contained in:
2026-01-01 12:10:35 +01:00
parent 02c0cc44e1
commit f001df3744
68 changed files with 10324 additions and 32 deletions

View File

@@ -0,0 +1,51 @@
import { AdminUser } from '../../domain/entities/AdminUser';
import { UserId } from '../../domain/value-objects/UserId';
import { Email } from '../../domain/value-objects/Email';
import { UserRole } from '../../domain/value-objects/UserRole';
import { UserStatus } from '../../domain/value-objects/UserStatus';
export interface UserFilter {
role?: UserRole;
status?: UserStatus;
email?: Email;
search?: string;
}
export interface UserSort {
field: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
direction: 'asc' | 'desc';
}
export interface UserPagination {
page: number;
limit: number;
}
export interface UserListQuery {
filter?: UserFilter;
sort?: UserSort | undefined;
pagination?: UserPagination | undefined;
}
export interface UserListResult {
users: AdminUser[];
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
* Output port for user management operations
* Implemented by infrastructure layer
*/
export interface IAdminUserRepository {
findById(id: UserId): Promise<AdminUser | null>;
findByEmail(email: Email): Promise<AdminUser | null>;
emailExists(email: Email): Promise<boolean>;
list(query?: UserListQuery): Promise<UserListResult>;
count(filter?: UserFilter): Promise<number>;
create(user: AdminUser): Promise<AdminUser>;
update(user: AdminUser): Promise<AdminUser>;
delete(id: UserId): Promise<void>;
}

View File

@@ -0,0 +1,394 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { ListUsersUseCase, ListUsersResult } from './ListUsersUseCase';
import { IAdminUserRepository } from '../ports/IAdminUserRepository';
import { AdminUser } from '../../domain/entities/AdminUser';
import { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { AuthorizationService } from '../../domain/services/AuthorizationService';
// Mock the authorization service
vi.mock('../../domain/services/AuthorizationService');
// Mock repository
const mockRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
} as unknown as IAdminUserRepository;
// Mock output port
const mockOutputPort = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<ListUsersResult>;
describe('ListUsersUseCase', () => {
let useCase: ListUsersUseCase;
let actor: AdminUser;
beforeEach(() => {
vi.clearAllMocks();
// Reset all mocks
vi.mocked(mockRepository.findById).mockReset();
vi.mocked(mockRepository.list).mockReset();
vi.mocked(mockRepository.count).mockReset();
vi.mocked(AuthorizationService.canListUsers).mockReset();
// Setup default successful authorization
vi.mocked(AuthorizationService.canListUsers).mockReturnValue(true);
useCase = new ListUsersUseCase(mockRepository, mockOutputPort);
// Create actor (owner)
actor = AdminUser.create({
id: 'actor-123',
email: 'owner@example.com',
roles: ['owner'],
status: 'active',
displayName: 'Owner User',
});
// Setup repository to return actor when findById is called
vi.mocked(mockRepository.findById).mockResolvedValue(actor);
});
describe('TDD - Test First', () => {
it('should return empty list when no users exist', async () => {
// Arrange
vi.mocked(mockRepository.list).mockResolvedValue({
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
});
vi.mocked(mockRepository.count).mockResolvedValue(0);
// Act
const result = await useCase.execute({
actorId: actor.id.value,
});
// Assert
expect(result.isOk()).toBe(true);
expect(mockOutputPort.present).toHaveBeenCalledWith({
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
});
});
it('should return users when they exist', async () => {
// Arrange
const user1 = AdminUser.create({
id: 'user-1',
email: 'user1@example.com',
roles: ['user'],
status: 'active',
displayName: 'User One',
});
const user2 = AdminUser.create({
id: 'user-2',
email: 'user2@example.com',
roles: ['admin'],
status: 'active',
displayName: 'User Two',
});
vi.mocked(mockRepository.list).mockResolvedValue({
users: [user1, user2],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
});
vi.mocked(mockRepository.count).mockResolvedValue(2);
// Act
const result = await useCase.execute({
actorId: actor.id.value,
});
// Assert
expect(result.isOk()).toBe(true);
expect(mockOutputPort.present).toHaveBeenCalledWith({
users: [user1, user2],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
});
});
it('should filter by role', async () => {
// Arrange
const adminUser = AdminUser.create({
id: 'admin-1',
email: 'admin@example.com',
roles: ['admin'],
status: 'active',
displayName: 'Admin User',
});
vi.mocked(mockRepository.list).mockResolvedValue({
users: [adminUser],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
});
vi.mocked(mockRepository.count).mockResolvedValue(1);
// Act
const result = await useCase.execute({
actorId: actor.id.value,
role: 'admin',
});
// Assert
expect(result.isOk()).toBe(true);
expect(mockRepository.list).toHaveBeenCalledWith(
expect.objectContaining({
filter: expect.objectContaining({
role: expect.objectContaining({ value: 'admin' }),
}),
})
);
});
it('should filter by status', async () => {
// Arrange
const suspendedUser = AdminUser.create({
id: 'suspended-1',
email: 'suspended@example.com',
roles: ['user'],
status: 'suspended',
displayName: 'Suspended User',
});
vi.mocked(mockRepository.list).mockResolvedValue({
users: [suspendedUser],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
});
vi.mocked(mockRepository.count).mockResolvedValue(1);
// Act
const result = await useCase.execute({
actorId: actor.id.value,
status: 'suspended',
});
// Assert
expect(result.isOk()).toBe(true);
expect(mockRepository.list).toHaveBeenCalledWith(
expect.objectContaining({
filter: expect.objectContaining({
status: expect.objectContaining({ value: 'suspended' }),
}),
})
);
});
it('should search by email or display name', async () => {
// Arrange
const matchingUser = AdminUser.create({
id: 'match-1',
email: 'search@example.com',
roles: ['user'],
status: 'active',
displayName: 'Search User',
});
vi.mocked(mockRepository.list).mockResolvedValue({
users: [matchingUser],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
});
vi.mocked(mockRepository.count).mockResolvedValue(1);
// Act
const result = await useCase.execute({
actorId: actor.id.value,
search: 'search',
});
// Assert
expect(result.isOk()).toBe(true);
expect(mockRepository.list).toHaveBeenCalledWith(
expect.objectContaining({
filter: expect.objectContaining({
search: 'search',
}),
})
);
});
it('should paginate results', async () => {
// Arrange
const users = Array.from({ length: 5 }, (_, i) =>
AdminUser.create({
id: `user-${i}`,
email: `user${i}@example.com`,
roles: ['user'],
status: 'active',
displayName: `User ${i}`,
})
);
vi.mocked(mockRepository.list).mockResolvedValue({
users: users.slice(0, 2),
total: 5,
page: 1,
limit: 2,
totalPages: 3,
});
vi.mocked(mockRepository.count).mockResolvedValue(5);
// Act
const result = await useCase.execute({
actorId: actor.id.value,
page: 1,
limit: 2,
});
// Assert
expect(result.isOk()).toBe(true);
expect(mockRepository.list).toHaveBeenCalledWith(
expect.objectContaining({
pagination: { page: 1, limit: 2 },
})
);
});
it('should sort results', async () => {
// Arrange
vi.mocked(mockRepository.list).mockResolvedValue({
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
});
vi.mocked(mockRepository.count).mockResolvedValue(0);
// Act
const result = await useCase.execute({
actorId: actor.id.value,
sortBy: 'email',
sortDirection: 'desc',
});
// Assert
expect(result.isOk()).toBe(true);
expect(mockRepository.list).toHaveBeenCalledWith(
expect.objectContaining({
sort: {
field: 'email',
direction: 'desc',
},
})
);
});
it('should return error when actor is not authorized', async () => {
// Arrange
const regularUser = AdminUser.create({
id: 'regular-1',
email: 'regular@example.com',
roles: ['user'],
status: 'active',
displayName: 'Regular User',
});
// Mock authorization to fail
vi.mocked(AuthorizationService.canListUsers).mockReturnValue(false);
// Act
const result = await useCase.execute({
actorId: regularUser.id.value,
});
// Assert
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('AUTHORIZATION_ERROR');
expect(error.details.message).toContain('not authorized');
});
it('should handle repository errors gracefully', async () => {
// Arrange
vi.mocked(mockRepository.list).mockRejectedValue(new Error('Database connection failed'));
// Act
const result = await useCase.execute({
actorId: actor.id.value,
});
// Assert
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toContain('Database connection failed');
});
it('should apply multiple filters together', async () => {
// Arrange
const matchingUser = AdminUser.create({
id: 'match-1',
email: 'admin@example.com',
roles: ['admin'],
status: 'active',
displayName: 'Admin User',
});
vi.mocked(mockRepository.list).mockResolvedValue({
users: [matchingUser],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
});
vi.mocked(mockRepository.count).mockResolvedValue(1);
// Act
const result = await useCase.execute({
actorId: actor.id.value,
role: 'admin',
status: 'active',
search: 'admin',
});
// Assert
expect(result.isOk()).toBe(true);
expect(mockRepository.list).toHaveBeenCalledWith(
expect.objectContaining({
filter: expect.objectContaining({
role: expect.objectContaining({ value: 'admin' }),
status: expect.objectContaining({ value: 'active' }),
search: 'admin',
}),
})
);
});
});
});

View File

@@ -0,0 +1,166 @@
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { IAdminUserRepository } from '../ports/IAdminUserRepository';
import { AuthorizationService } from '../../domain/services/AuthorizationService';
import { UserId } from '../../domain/value-objects/UserId';
import { UserRole } from '../../domain/value-objects/UserRole';
import { UserStatus } from '../../domain/value-objects/UserStatus';
import { Email } from '../../domain/value-objects/Email';
import type { AdminUser } from '../../domain/entities/AdminUser';
export type ListUsersInput = {
actorId: string;
role?: string;
status?: string;
email?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
sortDirection?: 'asc' | 'desc';
};
export type ListUsersResult = {
users: AdminUser[];
total: number;
page: number;
limit: number;
totalPages: number;
};
export type ListUsersErrorCode =
| 'USER_NOT_FOUND'
| 'VALIDATION_ERROR'
| 'AUTHORIZATION_ERROR'
| 'REPOSITORY_ERROR';
export type ListUsersApplicationError = ApplicationErrorCode<ListUsersErrorCode, { message: string; details?: unknown }>;
/**
* Application Use Case: ListUsersUseCase
*
* Lists users with filtering, sorting, and pagination.
* Only accessible to system administrators (Owner/Admin).
*/
export class ListUsersUseCase {
constructor(
private readonly adminUserRepository: IAdminUserRepository,
private readonly output: UseCaseOutputPort<ListUsersResult>,
) {}
async execute(
input: ListUsersInput,
): Promise<
Result<
void,
ListUsersApplicationError
>
> {
try {
// Get actor (current user)
const actor = await this.adminUserRepository.findById(UserId.fromString(input.actorId));
if (!actor) {
return Result.err({
code: 'AUTHORIZATION_ERROR',
details: { message: 'Actor not found' },
});
}
// Check authorization
if (!AuthorizationService.canListUsers(actor)) {
return Result.err({
code: 'AUTHORIZATION_ERROR',
details: { message: 'User is not authorized to list users' },
});
}
// Build filter
const filter: {
role?: UserRole;
status?: UserStatus;
email?: Email;
search?: string;
} = {};
if (input.role) {
filter.role = UserRole.fromString(input.role);
}
if (input.status) {
filter.status = UserStatus.fromString(input.status);
}
if (input.email) {
filter.email = Email.fromString(input.email);
}
if (input.search) {
filter.search = input.search;
}
// Build sort
const sort = input.sortBy ? {
field: input.sortBy,
direction: input.sortDirection || 'asc',
} : undefined;
// Build pagination
const pagination = input.page && input.limit ? {
page: input.page,
limit: input.limit,
} : undefined;
// Execute query
const query: {
filter?: {
role?: UserRole;
status?: UserStatus;
email?: Email;
search?: string;
};
sort?: {
field: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
direction: 'asc' | 'desc';
};
pagination?: {
page: number;
limit: number;
};
} = {};
if (Object.keys(filter).length > 0) {
query.filter = filter;
}
if (sort) {
query.sort = sort;
}
if (pagination) {
query.pagination = pagination;
}
const result = await this.adminUserRepository.list(query);
// Pass domain objects to output port
this.output.present({
users: result.users,
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
});
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list users';
if (error instanceof Error && error.message.includes('validation')) {
return Result.err({
code: 'VALIDATION_ERROR',
details: { message },
});
}
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}