admin area
This commit is contained in:
@@ -11,6 +11,7 @@ import { LeagueModule } from './domain/league/LeagueModule';
|
||||
import { LoggingModule } from './domain/logging/LoggingModule';
|
||||
import { MediaModule } from './domain/media/MediaModule';
|
||||
import { PaymentsModule } from './domain/payments/PaymentsModule';
|
||||
import { AdminModule } from './domain/admin/AdminModule';
|
||||
import { PolicyModule } from './domain/policy/PolicyModule';
|
||||
import { ProtestsModule } from './domain/protests/ProtestsModule';
|
||||
import { RaceModule } from './domain/race/RaceModule';
|
||||
@@ -44,6 +45,7 @@ const ENABLE_BOOTSTRAP = getEnableBootstrap();
|
||||
MediaModule,
|
||||
PaymentsModule,
|
||||
PolicyModule,
|
||||
AdminModule,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
67
apps/api/src/domain/admin/AdminController.ts
Normal file
67
apps/api/src/domain/admin/AdminController.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Inject, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminService } from './AdminService';
|
||||
import { ListUsersRequestDto } from './dtos/ListUsersRequestDto';
|
||||
import { UserListResponseDto } from './dtos/UserResponseDto';
|
||||
import { DashboardStatsResponseDto } from './dto/DashboardStatsResponseDto';
|
||||
import { RequireAuthenticatedUser } from '../auth/RequireAuthenticatedUser';
|
||||
import { RequireRoles } from '../auth/RequireRoles';
|
||||
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
|
||||
import type { Request } from 'express';
|
||||
import type { ListUsersInput } from '@core/admin/application/use-cases/ListUsersUseCase';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin')
|
||||
@UseGuards(AuthorizationGuard)
|
||||
export class AdminController {
|
||||
constructor(
|
||||
@Inject(AdminService) private readonly adminService: AdminService,
|
||||
) {}
|
||||
|
||||
@Get('users')
|
||||
@RequireAuthenticatedUser()
|
||||
@RequireRoles('owner', 'admin')
|
||||
@ApiOperation({ summary: 'List all users (Owner/Super Admin only)' })
|
||||
@ApiResponse({ status: 200, description: 'List of users', type: UserListResponseDto })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden - not a system admin' })
|
||||
async listUsers(
|
||||
@Query() query: ListUsersRequestDto,
|
||||
@Req() req: Request,
|
||||
): Promise<UserListResponseDto> {
|
||||
// Get actorId from request (will be set by auth middleware/guard)
|
||||
const actorId = (req as Request & { user?: { userId?: string } }).user?.userId || 'current-user';
|
||||
|
||||
// Build input with only defined values
|
||||
const input: ListUsersInput = {
|
||||
actorId,
|
||||
page: query.page || 1,
|
||||
limit: query.limit || 10,
|
||||
};
|
||||
|
||||
if (query.role !== undefined) input.role = query.role;
|
||||
if (query.status !== undefined) input.status = query.status;
|
||||
if (query.email !== undefined) input.email = query.email;
|
||||
if (query.search !== undefined) input.search = query.search;
|
||||
if (query.sortBy !== undefined) input.sortBy = query.sortBy;
|
||||
if (query.sortDirection !== undefined) input.sortDirection = query.sortDirection;
|
||||
|
||||
return await this.adminService.listUsers(input);
|
||||
}
|
||||
|
||||
@Get('dashboard/stats')
|
||||
@RequireAuthenticatedUser()
|
||||
@RequireRoles('owner', 'admin')
|
||||
@ApiOperation({ summary: 'Get dashboard statistics (Owner/Super Admin only)' })
|
||||
@ApiResponse({ status: 200, description: 'Dashboard statistics', type: DashboardStatsResponseDto })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden - not a system admin' })
|
||||
async getDashboardStats(
|
||||
@Req() req: Request,
|
||||
): Promise<DashboardStatsResponseDto> {
|
||||
// Get actorId from request (will be set by auth middleware/guard)
|
||||
const actorId = (req as Request & { user?: { userId?: string } }).user?.userId || 'current-user';
|
||||
|
||||
const input = { actorId };
|
||||
|
||||
return await this.adminService.getDashboardStats(input);
|
||||
}
|
||||
}
|
||||
53
apps/api/src/domain/admin/AdminModule.ts
Normal file
53
apps/api/src/domain/admin/AdminModule.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryAdminPersistenceModule } from '../../persistence/inmemory/InMemoryAdminPersistenceModule';
|
||||
import { AdminService } from './AdminService';
|
||||
import { AdminController } from './AdminController';
|
||||
import { ListUsersPresenter } from './presenters/ListUsersPresenter';
|
||||
import { DashboardStatsPresenter } from './presenters/DashboardStatsPresenter';
|
||||
import { AuthModule } from '../auth/AuthModule';
|
||||
import { ListUsersUseCase } from '@core/admin/application/use-cases/ListUsersUseCase';
|
||||
import { GetDashboardStatsUseCase } from './use-cases/GetDashboardStatsUseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { ListUsersResult } from '@core/admin/application/use-cases/ListUsersUseCase';
|
||||
import type { DashboardStatsResult } from './use-cases/GetDashboardStatsUseCase';
|
||||
import type { IAdminUserRepository } from '@core/admin/domain/repositories/IAdminUserRepository';
|
||||
|
||||
export const ADMIN_USER_REPOSITORY_TOKEN = 'IAdminUserRepository';
|
||||
export const LIST_USERS_OUTPUT_PORT_TOKEN = 'ListUsersOutputPort';
|
||||
export const DASHBOARD_STATS_OUTPUT_PORT_TOKEN = 'DashboardStatsOutputPort';
|
||||
|
||||
@Module({
|
||||
imports: [InMemoryAdminPersistenceModule, AuthModule],
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
AdminService,
|
||||
ListUsersPresenter,
|
||||
DashboardStatsPresenter,
|
||||
{
|
||||
provide: LIST_USERS_OUTPUT_PORT_TOKEN,
|
||||
useExisting: ListUsersPresenter,
|
||||
},
|
||||
{
|
||||
provide: DASHBOARD_STATS_OUTPUT_PORT_TOKEN,
|
||||
useExisting: DashboardStatsPresenter,
|
||||
},
|
||||
{
|
||||
provide: ListUsersUseCase,
|
||||
useFactory: (
|
||||
repository: IAdminUserRepository,
|
||||
output: UseCaseOutputPort<ListUsersResult>,
|
||||
) => new ListUsersUseCase(repository, output),
|
||||
inject: [ADMIN_USER_REPOSITORY_TOKEN, LIST_USERS_OUTPUT_PORT_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: GetDashboardStatsUseCase,
|
||||
useFactory: (
|
||||
repository: IAdminUserRepository,
|
||||
output: UseCaseOutputPort<DashboardStatsResult>,
|
||||
) => new GetDashboardStatsUseCase(repository, output),
|
||||
inject: [ADMIN_USER_REPOSITORY_TOKEN, DASHBOARD_STATS_OUTPUT_PORT_TOKEN],
|
||||
},
|
||||
],
|
||||
exports: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
334
apps/api/src/domain/admin/AdminService.test.ts
Normal file
334
apps/api/src/domain/admin/AdminService.test.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { AdminService } from './AdminService';
|
||||
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
// Mock use cases
|
||||
const mockListUsersUseCase = {
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGetDashboardStatsUseCase = {
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock presenters
|
||||
const mockListUsersPresenter = {
|
||||
present: vi.fn(),
|
||||
getViewModel: vi.fn(),
|
||||
};
|
||||
|
||||
const mockDashboardStatsPresenter = {
|
||||
present: vi.fn(),
|
||||
responseModel: {},
|
||||
reset: vi.fn(),
|
||||
};
|
||||
|
||||
describe('AdminService', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
let service: AdminService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new AdminService(
|
||||
mockListUsersUseCase as any,
|
||||
mockListUsersPresenter as any,
|
||||
mockGetDashboardStatsUseCase as any,
|
||||
mockDashboardStatsPresenter as any
|
||||
);
|
||||
});
|
||||
|
||||
describe('listUsers', () => {
|
||||
it('should return empty list when no users exist', async () => {
|
||||
// Arrange
|
||||
const expectedResult = {
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
|
||||
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
|
||||
|
||||
// Act
|
||||
const result = await service.listUsers({ actorId: 'actor-1' });
|
||||
|
||||
// Assert
|
||||
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith({ actorId: 'actor-1' });
|
||||
expect(mockListUsersPresenter.getViewModel).toHaveBeenCalled();
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should return users when they exist', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User 1',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
users: [user1, user2],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
|
||||
mockListUsersPresenter.getViewModel.mockReturnValue({
|
||||
users: [
|
||||
{ id: 'user-1', email: 'user1@example.com', displayName: 'User 1', roles: ['user'], status: 'active', isSystemAdmin: false, createdAt: user1.createdAt, updatedAt: user1.updatedAt },
|
||||
{ id: 'user-2', email: 'user2@example.com', displayName: 'User 2', roles: ['admin'], status: 'active', isSystemAdmin: true, createdAt: user2.createdAt, updatedAt: user2.updatedAt },
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.listUsers({ actorId: 'actor-1' });
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
});
|
||||
|
||||
it('should apply filters correctly', async () => {
|
||||
// Arrange
|
||||
const adminUser = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
users: [adminUser],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
|
||||
mockListUsersPresenter.getViewModel.mockReturnValue({
|
||||
users: [{ id: 'admin-1', email: 'admin@example.com', displayName: 'Admin', roles: ['admin'], status: 'active', isSystemAdmin: true, createdAt: adminUser.createdAt, updatedAt: adminUser.updatedAt }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.listUsers({
|
||||
actorId: 'actor-1',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'actor-1',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
expect(result.users).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should apply pagination correctly', async () => {
|
||||
// Arrange
|
||||
const expectedResult = {
|
||||
users: [],
|
||||
total: 50,
|
||||
page: 3,
|
||||
limit: 10,
|
||||
totalPages: 5,
|
||||
};
|
||||
|
||||
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
|
||||
mockListUsersPresenter.getViewModel.mockReturnValue({
|
||||
users: [],
|
||||
total: 50,
|
||||
page: 3,
|
||||
limit: 10,
|
||||
totalPages: 5,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.listUsers({
|
||||
actorId: 'actor-1',
|
||||
page: 3,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'actor-1',
|
||||
page: 3,
|
||||
limit: 10,
|
||||
})
|
||||
);
|
||||
expect(result.page).toBe(3);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.totalPages).toBe(5);
|
||||
});
|
||||
|
||||
it('should apply sorting correctly', async () => {
|
||||
// Arrange
|
||||
const expectedResult = {
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
|
||||
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
|
||||
|
||||
// Act
|
||||
await service.listUsers({
|
||||
actorId: 'actor-1',
|
||||
sortBy: 'email',
|
||||
sortDirection: 'desc',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'actor-1',
|
||||
sortBy: 'email',
|
||||
sortDirection: 'desc',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle search filter', async () => {
|
||||
// Arrange
|
||||
const expectedResult = {
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
|
||||
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
|
||||
|
||||
// Act
|
||||
await service.listUsers({
|
||||
actorId: 'actor-1',
|
||||
search: 'test',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'actor-1',
|
||||
search: 'test',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle email filter', async () => {
|
||||
// Arrange
|
||||
const expectedResult = {
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
|
||||
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
|
||||
|
||||
// Act
|
||||
await service.listUsers({
|
||||
actorId: 'actor-1',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'actor-1',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when use case fails', async () => {
|
||||
// Arrange
|
||||
const error = { code: 'UNAUTHORIZED', details: { message: 'Not authorized' } };
|
||||
mockListUsersUseCase.execute.mockResolvedValue(Result.err(error));
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
service.listUsers({ actorId: 'actor-1' })
|
||||
).rejects.toThrow('UNAUTHORIZED: Not authorized');
|
||||
});
|
||||
|
||||
it('should handle all filters together', async () => {
|
||||
// Arrange
|
||||
const expectedResult = {
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
|
||||
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
|
||||
|
||||
// Act
|
||||
await service.listUsers({
|
||||
actorId: 'actor-1',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
email: 'admin@example.com',
|
||||
search: 'admin',
|
||||
page: 2,
|
||||
limit: 20,
|
||||
sortBy: 'displayName',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'actor-1',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
email: 'admin@example.com',
|
||||
search: 'admin',
|
||||
page: 2,
|
||||
limit: 20,
|
||||
sortBy: 'displayName',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
39
apps/api/src/domain/admin/AdminService.ts
Normal file
39
apps/api/src/domain/admin/AdminService.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ListUsersUseCase, ListUsersInput } from '@core/admin/application/use-cases/ListUsersUseCase';
|
||||
import { ListUsersPresenter, ListUsersViewModel } from './presenters/ListUsersPresenter';
|
||||
import { GetDashboardStatsUseCase, GetDashboardStatsInput } from './use-cases/GetDashboardStatsUseCase';
|
||||
import { DashboardStatsPresenter, DashboardStatsResponse } from './presenters/DashboardStatsPresenter';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly listUsersUseCase: ListUsersUseCase,
|
||||
private readonly listUsersPresenter: ListUsersPresenter,
|
||||
private readonly getDashboardStatsUseCase: GetDashboardStatsUseCase,
|
||||
private readonly dashboardStatsPresenter: DashboardStatsPresenter,
|
||||
) {}
|
||||
|
||||
async listUsers(input: ListUsersInput): Promise<ListUsersViewModel> {
|
||||
const result = await this.listUsersUseCase.execute(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.unwrapErr();
|
||||
throw new Error(`${error.code}: ${error.details.message}`);
|
||||
}
|
||||
|
||||
return this.listUsersPresenter.getViewModel();
|
||||
}
|
||||
|
||||
async getDashboardStats(input: GetDashboardStatsInput): Promise<DashboardStatsResponse> {
|
||||
this.dashboardStatsPresenter.reset();
|
||||
|
||||
const result = await this.getDashboardStatsUseCase.execute(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.unwrapErr();
|
||||
throw new Error(`${error.code}: ${error.details.message}`);
|
||||
}
|
||||
|
||||
return this.dashboardStatsPresenter.responseModel;
|
||||
}
|
||||
}
|
||||
13
apps/api/src/domain/admin/RequireSystemAdmin.ts
Normal file
13
apps/api/src/domain/admin/RequireSystemAdmin.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const REQUIRE_SYSTEM_ADMIN_METADATA_KEY = 'gridpilot:requireSystemAdmin';
|
||||
|
||||
export type RequireSystemAdminMetadata = {
|
||||
readonly required: true;
|
||||
};
|
||||
|
||||
export function RequireSystemAdmin(): MethodDecorator & ClassDecorator {
|
||||
return SetMetadata(REQUIRE_SYSTEM_ADMIN_METADATA_KEY, {
|
||||
required: true,
|
||||
} satisfies RequireSystemAdminMetadata);
|
||||
}
|
||||
80
apps/api/src/domain/admin/dto/DashboardStatsResponseDto.ts
Normal file
80
apps/api/src/domain/admin/dto/DashboardStatsResponseDto.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
class UserGrowthDto {
|
||||
@ApiProperty({ description: 'Label for the time period' })
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: 'Number of new users' })
|
||||
value!: number;
|
||||
|
||||
@ApiProperty({ description: 'Color class for the bar' })
|
||||
color!: string;
|
||||
}
|
||||
|
||||
class RoleDistributionDto {
|
||||
@ApiProperty({ description: 'Role name' })
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: 'Number of users with this role' })
|
||||
value!: number;
|
||||
|
||||
@ApiProperty({ description: 'Color class for the bar' })
|
||||
color!: string;
|
||||
}
|
||||
|
||||
class StatusDistributionDto {
|
||||
@ApiProperty({ description: 'Number of active users' })
|
||||
active!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of suspended users' })
|
||||
suspended!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of deleted users' })
|
||||
deleted!: number;
|
||||
}
|
||||
|
||||
class ActivityTimelineDto {
|
||||
@ApiProperty({ description: 'Date label' })
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ description: 'Number of new users' })
|
||||
newUsers!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of logins' })
|
||||
logins!: number;
|
||||
}
|
||||
|
||||
export class DashboardStatsResponseDto {
|
||||
@ApiProperty({ description: 'Total number of users' })
|
||||
totalUsers!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of active users' })
|
||||
activeUsers!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of suspended users' })
|
||||
suspendedUsers!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of deleted users' })
|
||||
deletedUsers!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of system admins' })
|
||||
systemAdmins!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of recent logins (last 24h)' })
|
||||
recentLogins!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of new users today' })
|
||||
newUsersToday!: number;
|
||||
|
||||
@ApiProperty({ type: [UserGrowthDto], description: 'User growth over last 7 days' })
|
||||
userGrowth!: UserGrowthDto[];
|
||||
|
||||
@ApiProperty({ type: [RoleDistributionDto], description: 'Distribution of user roles' })
|
||||
roleDistribution!: RoleDistributionDto[];
|
||||
|
||||
@ApiProperty({ type: StatusDistributionDto, description: 'Distribution of user statuses' })
|
||||
statusDistribution!: StatusDistributionDto;
|
||||
|
||||
@ApiProperty({ type: [ActivityTimelineDto], description: 'Activity timeline for last 7 days' })
|
||||
activityTimeline!: ActivityTimelineDto[];
|
||||
}
|
||||
119
apps/api/src/domain/admin/dtos/ListUsersRequestDto.test.ts
Normal file
119
apps/api/src/domain/admin/dtos/ListUsersRequestDto.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ListUsersRequestDto } from './ListUsersRequestDto';
|
||||
|
||||
describe('ListUsersRequestDto', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
it('should create valid DTO with all fields', () => {
|
||||
// Arrange & Act
|
||||
const dto = new ListUsersRequestDto();
|
||||
dto.role = 'admin';
|
||||
dto.status = 'active';
|
||||
dto.email = 'test@example.com';
|
||||
dto.search = 'test';
|
||||
dto.page = 2;
|
||||
dto.limit = 20;
|
||||
dto.sortBy = 'email';
|
||||
dto.sortDirection = 'desc';
|
||||
|
||||
// Assert
|
||||
expect(dto.role).toBe('admin');
|
||||
expect(dto.status).toBe('active');
|
||||
expect(dto.email).toBe('test@example.com');
|
||||
expect(dto.search).toBe('test');
|
||||
expect(dto.page).toBe(2);
|
||||
expect(dto.limit).toBe(20);
|
||||
expect(dto.sortBy).toBe('email');
|
||||
expect(dto.sortDirection).toBe('desc');
|
||||
});
|
||||
|
||||
it('should create DTO with optional fields undefined', () => {
|
||||
// Arrange & Act
|
||||
const dto = new ListUsersRequestDto();
|
||||
|
||||
// Assert
|
||||
expect(dto.role).toBeUndefined();
|
||||
expect(dto.status).toBeUndefined();
|
||||
expect(dto.email).toBeUndefined();
|
||||
expect(dto.search).toBeUndefined();
|
||||
expect(dto.page).toBeUndefined();
|
||||
expect(dto.limit).toBeUndefined();
|
||||
expect(dto.sortBy).toBeUndefined();
|
||||
expect(dto.sortDirection).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle partial input', () => {
|
||||
// Arrange & Act
|
||||
const dto = new ListUsersRequestDto();
|
||||
dto.page = 1;
|
||||
dto.limit = 10;
|
||||
|
||||
// Assert
|
||||
expect(dto.page).toBe(1);
|
||||
expect(dto.limit).toBe(10);
|
||||
expect(dto.role).toBeUndefined();
|
||||
expect(dto.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept string values for all fields', () => {
|
||||
// Arrange & Act
|
||||
const dto = new ListUsersRequestDto();
|
||||
dto.role = 'owner';
|
||||
dto.status = 'suspended';
|
||||
dto.email = 'suspended@example.com';
|
||||
dto.search = 'suspended';
|
||||
dto.page = 3;
|
||||
dto.limit = 50;
|
||||
dto.sortBy = 'displayName';
|
||||
dto.sortDirection = 'asc';
|
||||
|
||||
// Assert
|
||||
expect(dto.role).toBe('owner');
|
||||
expect(dto.status).toBe('suspended');
|
||||
expect(dto.email).toBe('suspended@example.com');
|
||||
expect(dto.search).toBe('suspended');
|
||||
expect(dto.page).toBe(3);
|
||||
expect(dto.limit).toBe(50);
|
||||
expect(dto.sortBy).toBe('displayName');
|
||||
expect(dto.sortDirection).toBe('asc');
|
||||
});
|
||||
|
||||
it('should handle numeric string values for pagination', () => {
|
||||
// Arrange & Act
|
||||
const dto = new ListUsersRequestDto();
|
||||
dto.page = '5' as any;
|
||||
dto.limit = '25' as any;
|
||||
|
||||
// Assert - Should accept the values
|
||||
expect(dto.page).toBe('5');
|
||||
expect(dto.limit).toBe('25');
|
||||
});
|
||||
|
||||
it('should handle empty string values', () => {
|
||||
// Arrange & Act
|
||||
const dto = new ListUsersRequestDto();
|
||||
dto.role = '';
|
||||
dto.search = '';
|
||||
|
||||
// Assert
|
||||
expect(dto.role).toBe('');
|
||||
expect(dto.search).toBe('');
|
||||
});
|
||||
|
||||
it('should handle special characters in search', () => {
|
||||
// Arrange & Act
|
||||
const dto = new ListUsersRequestDto();
|
||||
dto.search = 'test@example.com';
|
||||
|
||||
// Assert
|
||||
expect(dto.search).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
// Arrange & Act
|
||||
const dto = new ListUsersRequestDto();
|
||||
dto.search = 'tëst ñame';
|
||||
|
||||
// Assert
|
||||
expect(dto.search).toBe('tëst ñame');
|
||||
});
|
||||
});
|
||||
});
|
||||
73
apps/api/src/domain/admin/dtos/ListUsersRequestDto.ts
Normal file
73
apps/api/src/domain/admin/dtos/ListUsersRequestDto.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsNumber, Min, Max } from 'class-validator';
|
||||
|
||||
export class ListUsersRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by user role',
|
||||
enum: ['owner', 'admin', 'user'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
role?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by user status',
|
||||
enum: ['active', 'suspended', 'deleted'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by email',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Search by email or display name',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Page number',
|
||||
default: 1,
|
||||
minimum: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Items per page',
|
||||
default: 10,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Sort field',
|
||||
enum: ['email', 'displayName', 'createdAt', 'lastLoginAt', 'status'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Sort direction',
|
||||
enum: ['asc', 'desc'],
|
||||
default: 'asc',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
}
|
||||
232
apps/api/src/domain/admin/dtos/UserResponseDto.test.ts
Normal file
232
apps/api/src/domain/admin/dtos/UserResponseDto.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { UserResponseDto } from './UserResponseDto';
|
||||
|
||||
describe('UserResponseDto', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
it('should create valid DTO with all fields', () => {
|
||||
// Arrange & Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = 'Test User';
|
||||
dto.roles = ['owner', 'admin'];
|
||||
dto.status = 'active';
|
||||
dto.isSystemAdmin = true;
|
||||
dto.createdAt = new Date('2024-01-01T00:00:00Z');
|
||||
dto.updatedAt = new Date('2024-01-02T00:00:00Z');
|
||||
dto.lastLoginAt = new Date('2024-01-03T00:00:00Z');
|
||||
dto.primaryDriverId = 'driver-456';
|
||||
|
||||
// Assert
|
||||
expect(dto.id).toBe('user-123');
|
||||
expect(dto.email).toBe('test@example.com');
|
||||
expect(dto.displayName).toBe('Test User');
|
||||
expect(dto.roles).toEqual(['owner', 'admin']);
|
||||
expect(dto.status).toBe('active');
|
||||
expect(dto.isSystemAdmin).toBe(true);
|
||||
expect(dto.createdAt).toEqual(new Date('2024-01-01T00:00:00Z'));
|
||||
expect(dto.updatedAt).toEqual(new Date('2024-01-02T00:00:00Z'));
|
||||
expect(dto.lastLoginAt).toEqual(new Date('2024-01-03T00:00:00Z'));
|
||||
expect(dto.primaryDriverId).toBe('driver-456');
|
||||
});
|
||||
|
||||
it('should create DTO with required fields only', () => {
|
||||
// Arrange & Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = 'Test User';
|
||||
dto.roles = ['user'];
|
||||
dto.status = 'active';
|
||||
dto.isSystemAdmin = false;
|
||||
dto.createdAt = new Date();
|
||||
dto.updatedAt = new Date();
|
||||
|
||||
// Assert
|
||||
expect(dto.id).toBe('user-123');
|
||||
expect(dto.email).toBe('test@example.com');
|
||||
expect(dto.displayName).toBe('Test User');
|
||||
expect(dto.roles).toEqual(['user']);
|
||||
expect(dto.status).toBe('active');
|
||||
expect(dto.isSystemAdmin).toBe(false);
|
||||
expect(dto.createdAt).toBeInstanceOf(Date);
|
||||
expect(dto.updatedAt).toBeInstanceOf(Date);
|
||||
expect(dto.lastLoginAt).toBeUndefined();
|
||||
expect(dto.primaryDriverId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty roles array', () => {
|
||||
// Arrange & Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = 'Test User';
|
||||
dto.roles = [];
|
||||
dto.status = 'active';
|
||||
dto.isSystemAdmin = false;
|
||||
dto.createdAt = new Date();
|
||||
dto.updatedAt = new Date();
|
||||
|
||||
// Assert
|
||||
expect(dto.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle single role', () => {
|
||||
// Arrange & Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = 'Test User';
|
||||
dto.roles = ['admin'];
|
||||
dto.status = 'active';
|
||||
dto.isSystemAdmin = true;
|
||||
dto.createdAt = new Date();
|
||||
dto.updatedAt = new Date();
|
||||
|
||||
// Assert
|
||||
expect(dto.roles).toEqual(['admin']);
|
||||
expect(dto.isSystemAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle suspended status', () => {
|
||||
// Arrange & Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = 'Test User';
|
||||
dto.roles = ['user'];
|
||||
dto.status = 'suspended';
|
||||
dto.isSystemAdmin = false;
|
||||
dto.createdAt = new Date();
|
||||
dto.updatedAt = new Date();
|
||||
|
||||
// Assert
|
||||
expect(dto.status).toBe('suspended');
|
||||
});
|
||||
|
||||
it('should handle deleted status', () => {
|
||||
// Arrange & Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = 'Test User';
|
||||
dto.roles = ['user'];
|
||||
dto.status = 'deleted';
|
||||
dto.isSystemAdmin = false;
|
||||
dto.createdAt = new Date();
|
||||
dto.updatedAt = new Date();
|
||||
|
||||
// Assert
|
||||
expect(dto.status).toBe('deleted');
|
||||
});
|
||||
|
||||
it('should handle very long display names', () => {
|
||||
// Arrange
|
||||
const longName = 'A'.repeat(100);
|
||||
|
||||
// Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = longName;
|
||||
dto.roles = ['user'];
|
||||
dto.status = 'active';
|
||||
dto.isSystemAdmin = false;
|
||||
dto.createdAt = new Date();
|
||||
dto.updatedAt = new Date();
|
||||
|
||||
// Assert
|
||||
expect(dto.displayName).toBe(longName);
|
||||
});
|
||||
|
||||
it('should handle special characters in email', () => {
|
||||
// Arrange & Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test+tag@example.co.uk';
|
||||
dto.displayName = 'Test User';
|
||||
dto.roles = ['user'];
|
||||
dto.status = 'active';
|
||||
dto.isSystemAdmin = false;
|
||||
dto.createdAt = new Date();
|
||||
dto.updatedAt = new Date();
|
||||
|
||||
// Assert
|
||||
expect(dto.email).toBe('test+tag@example.co.uk');
|
||||
});
|
||||
|
||||
it('should handle unicode in display name', () => {
|
||||
// Arrange & Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = 'Tëst Ñame';
|
||||
dto.roles = ['user'];
|
||||
dto.status = 'active';
|
||||
dto.isSystemAdmin = false;
|
||||
dto.createdAt = new Date();
|
||||
dto.updatedAt = new Date();
|
||||
|
||||
// Assert
|
||||
expect(dto.displayName).toBe('Tëst Ñame');
|
||||
});
|
||||
|
||||
it('should handle UUID format for ID', () => {
|
||||
// Arrange
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
// Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = uuid;
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = 'Test User';
|
||||
dto.roles = ['user'];
|
||||
dto.status = 'active';
|
||||
dto.isSystemAdmin = false;
|
||||
dto.createdAt = new Date();
|
||||
dto.updatedAt = new Date();
|
||||
|
||||
// Assert
|
||||
expect(dto.id).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should handle numeric primary driver ID', () => {
|
||||
// Arrange & Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = 'Test User';
|
||||
dto.roles = ['user'];
|
||||
dto.status = 'active';
|
||||
dto.isSystemAdmin = false;
|
||||
dto.createdAt = new Date();
|
||||
dto.updatedAt = new Date();
|
||||
dto.primaryDriverId = '123456';
|
||||
|
||||
// Assert
|
||||
expect(dto.primaryDriverId).toBe('123456');
|
||||
});
|
||||
|
||||
it('should handle very old and very new dates', () => {
|
||||
// Arrange
|
||||
const oldDate = new Date('1970-01-01T00:00:00Z');
|
||||
const newDate = new Date('2099-12-31T23:59:59Z');
|
||||
|
||||
// Act
|
||||
const dto = new UserResponseDto();
|
||||
dto.id = 'user-123';
|
||||
dto.email = 'test@example.com';
|
||||
dto.displayName = 'Test User';
|
||||
dto.roles = ['user'];
|
||||
dto.status = 'active';
|
||||
dto.isSystemAdmin = false;
|
||||
dto.createdAt = oldDate;
|
||||
dto.updatedAt = newDate;
|
||||
dto.lastLoginAt = newDate;
|
||||
|
||||
// Assert
|
||||
expect(dto.createdAt).toEqual(oldDate);
|
||||
expect(dto.updatedAt).toEqual(newDate);
|
||||
expect(dto.lastLoginAt).toEqual(newDate);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
apps/api/src/domain/admin/dtos/UserResponseDto.ts
Normal file
50
apps/api/src/domain/admin/dtos/UserResponseDto.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UserResponseDto {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
id: string = '';
|
||||
|
||||
@ApiProperty({ description: 'User email' })
|
||||
email: string = '';
|
||||
|
||||
@ApiProperty({ description: 'Display name' })
|
||||
displayName: string = '';
|
||||
|
||||
@ApiProperty({ description: 'User roles', type: [String] })
|
||||
roles: string[] = [];
|
||||
|
||||
@ApiProperty({ description: 'User status' })
|
||||
status: string = '';
|
||||
|
||||
@ApiProperty({ description: 'Whether user is system admin' })
|
||||
isSystemAdmin: boolean = false;
|
||||
|
||||
@ApiProperty({ description: 'Account creation date' })
|
||||
createdAt: Date = new Date();
|
||||
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt: Date = new Date();
|
||||
|
||||
@ApiPropertyOptional({ description: 'Last login date' })
|
||||
lastLoginAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Primary driver ID' })
|
||||
primaryDriverId?: string;
|
||||
}
|
||||
|
||||
export class UserListResponseDto {
|
||||
@ApiProperty({ description: 'List of users', type: [UserResponseDto] })
|
||||
users: UserResponseDto[] = [];
|
||||
|
||||
@ApiProperty({ description: 'Total number of users' })
|
||||
total: number = 0;
|
||||
|
||||
@ApiProperty({ description: 'Current page number' })
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ description: 'Items per page' })
|
||||
limit: number = 10;
|
||||
|
||||
@ApiProperty({ description: 'Total number of pages' })
|
||||
totalPages: number = 0;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { DashboardStatsResult } from '../use-cases/GetDashboardStatsUseCase';
|
||||
|
||||
export interface DashboardStatsResponse {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
suspendedUsers: number;
|
||||
deletedUsers: number;
|
||||
systemAdmins: number;
|
||||
recentLogins: number;
|
||||
newUsersToday: number;
|
||||
userGrowth: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
roleDistribution: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
statusDistribution: {
|
||||
active: number;
|
||||
suspended: number;
|
||||
deleted: number;
|
||||
};
|
||||
activityTimeline: {
|
||||
date: string;
|
||||
newUsers: number;
|
||||
logins: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export class DashboardStatsPresenter implements UseCaseOutputPort<DashboardStatsResult> {
|
||||
private _responseModel: DashboardStatsResponse | null = null;
|
||||
|
||||
present(result: DashboardStatsResult): void {
|
||||
this._responseModel = {
|
||||
totalUsers: result.totalUsers,
|
||||
activeUsers: result.activeUsers,
|
||||
suspendedUsers: result.suspendedUsers,
|
||||
deletedUsers: result.deletedUsers,
|
||||
systemAdmins: result.systemAdmins,
|
||||
recentLogins: result.recentLogins,
|
||||
newUsersToday: result.newUsersToday,
|
||||
userGrowth: result.userGrowth,
|
||||
roleDistribution: result.roleDistribution,
|
||||
statusDistribution: result.statusDistribution,
|
||||
activityTimeline: result.activityTimeline,
|
||||
};
|
||||
}
|
||||
|
||||
get responseModel(): DashboardStatsResponse {
|
||||
if (!this._responseModel) {
|
||||
throw new Error('No response model available. Call present() first.');
|
||||
}
|
||||
return this._responseModel;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._responseModel = null;
|
||||
}
|
||||
}
|
||||
398
apps/api/src/domain/admin/presenters/ListUsersPresenter.test.ts
Normal file
398
apps/api/src/domain/admin/presenters/ListUsersPresenter.test.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { ListUsersPresenter } from './ListUsersPresenter';
|
||||
import { ListUsersResult } from '@core/admin/application/use-cases/ListUsersUseCase';
|
||||
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||
|
||||
describe('ListUsersPresenter', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
it('should convert result to response DTO with all fields', () => {
|
||||
// Arrange - Create domain objects
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User One',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
lastLoginAt: new Date('2024-01-03T00:00:00Z'),
|
||||
primaryDriverId: 'driver-1',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User Two',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
createdAt: new Date('2024-01-04T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-05T00:00:00Z'),
|
||||
lastLoginAt: new Date('2024-01-06T00:00:00Z'),
|
||||
primaryDriverId: 'driver-2',
|
||||
});
|
||||
|
||||
const result: ListUsersResult = {
|
||||
users: [user1, user2],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
expect(response.users).toHaveLength(2);
|
||||
expect(response.total).toBe(2);
|
||||
expect(response.page).toBe(1);
|
||||
expect(response.limit).toBe(10);
|
||||
expect(response.totalPages).toBe(1);
|
||||
|
||||
// Check first user
|
||||
const user0 = response.users[0];
|
||||
expect(user0).toBeDefined();
|
||||
if (user0) {
|
||||
expect(user0.id).toBe('user-1');
|
||||
expect(user0.email).toBe('user1@example.com');
|
||||
expect(user0.displayName).toBe('User One');
|
||||
expect(user0.roles).toEqual(['user']);
|
||||
expect(user0.status).toBe('active');
|
||||
expect(user0.isSystemAdmin).toBe(false);
|
||||
expect(user0.createdAt).toEqual(new Date('2024-01-01T00:00:00Z'));
|
||||
expect(user0.updatedAt).toEqual(new Date('2024-01-02T00:00:00Z'));
|
||||
expect(user0.lastLoginAt).toEqual(new Date('2024-01-03T00:00:00Z'));
|
||||
expect(user0.primaryDriverId).toBe('driver-1');
|
||||
}
|
||||
|
||||
// Check second user
|
||||
const user1Response = response.users[1];
|
||||
expect(user1Response).toBeDefined();
|
||||
if (user1Response) {
|
||||
expect(user1Response.id).toBe('user-2');
|
||||
expect(user1Response.email).toBe('user2@example.com');
|
||||
expect(user1Response.displayName).toBe('User Two');
|
||||
expect(user1Response.roles).toEqual(['owner']);
|
||||
expect(user1Response.status).toBe('active');
|
||||
expect(user1Response.isSystemAdmin).toBe(true);
|
||||
expect(user1Response.createdAt).toEqual(new Date('2024-01-04T00:00:00Z'));
|
||||
expect(user1Response.updatedAt).toEqual(new Date('2024-01-05T00:00:00Z'));
|
||||
expect(user1Response.lastLoginAt).toEqual(new Date('2024-01-06T00:00:00Z'));
|
||||
expect(user1Response.primaryDriverId).toBe('driver-2');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty user list', () => {
|
||||
// Arrange
|
||||
const result: ListUsersResult = {
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
expect(response.users).toEqual([]);
|
||||
expect(response.total).toBe(0);
|
||||
expect(response.page).toBe(1);
|
||||
expect(response.limit).toBe(10);
|
||||
expect(response.totalPages).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle users without optional fields', () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User One',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result: ListUsersResult = {
|
||||
users: [user1],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
expect(response.users).toHaveLength(1);
|
||||
const user0 = response.users[0];
|
||||
expect(user0).toBeDefined();
|
||||
if (user0) {
|
||||
expect(user0.lastLoginAt).toBeUndefined();
|
||||
expect(user0.primaryDriverId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle users with multiple roles', () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User One',
|
||||
roles: ['owner', 'admin'],
|
||||
status: 'active',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result: ListUsersResult = {
|
||||
users: [user1],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
const user0 = response.users[0];
|
||||
expect(user0).toBeDefined();
|
||||
if (user0) {
|
||||
expect(user0.roles).toEqual(['owner', 'admin']);
|
||||
expect(user0.isSystemAdmin).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle suspended users', () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User One',
|
||||
roles: ['user'],
|
||||
status: 'suspended',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result: ListUsersResult = {
|
||||
users: [user1],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
const user0 = response.users[0];
|
||||
expect(user0).toBeDefined();
|
||||
if (user0) {
|
||||
expect(user0.status).toBe('suspended');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle deleted users', () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User One',
|
||||
roles: ['user'],
|
||||
status: 'deleted',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result: ListUsersResult = {
|
||||
users: [user1],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
const user0 = response.users[0];
|
||||
expect(user0).toBeDefined();
|
||||
if (user0) {
|
||||
expect(user0.status).toBe('deleted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle pagination metadata correctly', () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User One',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result: ListUsersResult = {
|
||||
users: [user1],
|
||||
total: 15,
|
||||
page: 2,
|
||||
limit: 5,
|
||||
totalPages: 3,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
expect(response.total).toBe(15);
|
||||
expect(response.page).toBe(2);
|
||||
expect(response.limit).toBe(5);
|
||||
expect(response.totalPages).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle special characters in user data', () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test+tag@example.co.uk',
|
||||
displayName: 'Tëst Ñame',
|
||||
roles: ['admin-steward'],
|
||||
status: 'under-review',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
primaryDriverId: 'driver-123',
|
||||
});
|
||||
|
||||
const result: ListUsersResult = {
|
||||
users: [user1],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
const user0 = response.users[0];
|
||||
expect(user0).toBeDefined();
|
||||
if (user0) {
|
||||
expect(user0.email).toBe('test+tag@example.co.uk');
|
||||
expect(user0.displayName).toBe('Tëst Ñame');
|
||||
expect(user0.roles).toEqual(['admin-steward']);
|
||||
expect(user0.status).toBe('under-review');
|
||||
expect(user0.primaryDriverId).toBe('driver-123');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle very long display names', () => {
|
||||
// Arrange
|
||||
const longName = 'A'.repeat(100);
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: longName,
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result: ListUsersResult = {
|
||||
users: [user1],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
const user0 = response.users[0];
|
||||
expect(user0).toBeDefined();
|
||||
if (user0) {
|
||||
expect(user0.displayName).toBe(longName);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle UUID format for IDs', () => {
|
||||
// Arrange
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const user1 = AdminUser.create({
|
||||
id: uuid,
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result: ListUsersResult = {
|
||||
users: [user1],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
const user0 = response.users[0];
|
||||
expect(user0).toBeDefined();
|
||||
if (user0) {
|
||||
expect(user0.id).toBe(uuid);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle very old and very new dates', () => {
|
||||
// Arrange
|
||||
const oldDate = new Date('1970-01-01T00:00:00Z');
|
||||
const newDate = new Date('2099-12-31T23:59:59Z');
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
createdAt: oldDate,
|
||||
updatedAt: newDate,
|
||||
lastLoginAt: newDate,
|
||||
});
|
||||
|
||||
const result: ListUsersResult = {
|
||||
users: [user1],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = ListUsersPresenter.toResponse(result);
|
||||
|
||||
// Assert
|
||||
const user0 = response.users[0];
|
||||
expect(user0).toBeDefined();
|
||||
if (user0) {
|
||||
expect(user0.createdAt).toEqual(oldDate);
|
||||
expect(user0.updatedAt).toEqual(newDate);
|
||||
expect(user0.lastLoginAt).toEqual(newDate);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
85
apps/api/src/domain/admin/presenters/ListUsersPresenter.ts
Normal file
85
apps/api/src/domain/admin/presenters/ListUsersPresenter.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { ListUsersResult } from '@core/admin/application/use-cases/ListUsersUseCase';
|
||||
import { UserListResponseDto, UserResponseDto } from '../dtos/UserResponseDto';
|
||||
import type { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||
|
||||
export type ListUsersViewModel = UserListResponseDto;
|
||||
|
||||
/**
|
||||
* Presenter to convert application layer result to API response DTO
|
||||
* Implements UseCaseOutputPort to receive results from the use case
|
||||
*/
|
||||
export class ListUsersPresenter implements UseCaseOutputPort<ListUsersResult> {
|
||||
private viewModel!: ListUsersViewModel;
|
||||
|
||||
present(result: ListUsersResult): void {
|
||||
this.viewModel = {
|
||||
users: result.users.map(user => this.toUserResponse(user)),
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
totalPages: result.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): ListUsersViewModel {
|
||||
if (!this.viewModel) {
|
||||
throw new Error('Presenter not presented');
|
||||
}
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
// Static method for backward compatibility with tests
|
||||
static toResponse(result: ListUsersResult): UserListResponseDto {
|
||||
const presenter = new ListUsersPresenter();
|
||||
presenter.present(result);
|
||||
return presenter.getViewModel();
|
||||
}
|
||||
|
||||
private toUserResponse(user: AdminUser | Record<string, unknown>): UserResponseDto {
|
||||
const response = new UserResponseDto();
|
||||
|
||||
// Handle both domain objects and plain objects
|
||||
if (user.id && typeof user.id === 'object' && 'value' in (user.id as Record<string, unknown>)) {
|
||||
// Domain object
|
||||
const domainUser = user as AdminUser;
|
||||
response.id = domainUser.id.value;
|
||||
response.email = domainUser.email.value;
|
||||
response.displayName = domainUser.displayName;
|
||||
response.roles = domainUser.roles.map(r => r.value);
|
||||
response.status = domainUser.status.value;
|
||||
response.isSystemAdmin = domainUser.isSystemAdmin();
|
||||
response.createdAt = domainUser.createdAt;
|
||||
response.updatedAt = domainUser.updatedAt;
|
||||
|
||||
if (domainUser.lastLoginAt) {
|
||||
response.lastLoginAt = domainUser.lastLoginAt;
|
||||
}
|
||||
|
||||
if (domainUser.primaryDriverId) {
|
||||
response.primaryDriverId = domainUser.primaryDriverId;
|
||||
}
|
||||
} else {
|
||||
// Plain object (for tests)
|
||||
const plainUser = user as Record<string, unknown>;
|
||||
response.id = plainUser.id as string;
|
||||
response.email = plainUser.email as string;
|
||||
response.displayName = plainUser.displayName as string;
|
||||
response.roles = plainUser.roles as string[];
|
||||
response.status = plainUser.status as string;
|
||||
response.isSystemAdmin = plainUser.isSystemAdmin as boolean;
|
||||
response.createdAt = plainUser.createdAt as Date;
|
||||
response.updatedAt = plainUser.updatedAt as Date;
|
||||
|
||||
if (plainUser.lastLoginAt) {
|
||||
response.lastLoginAt = plainUser.lastLoginAt as Date;
|
||||
}
|
||||
|
||||
if (plainUser.primaryDriverId) {
|
||||
response.primaryDriverId = plainUser.primaryDriverId as string;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
180
apps/api/src/domain/admin/use-cases/GetDashboardStatsUseCase.ts
Normal file
180
apps/api/src/domain/admin/use-cases/GetDashboardStatsUseCase.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
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 '@core/admin/domain/repositories/IAdminUserRepository';
|
||||
import { AuthorizationService } from '@core/admin/domain/services/AuthorizationService';
|
||||
import { UserId } from '@core/admin/domain/value-objects/UserId';
|
||||
|
||||
export interface DashboardStatsResult {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
suspendedUsers: number;
|
||||
deletedUsers: number;
|
||||
systemAdmins: number;
|
||||
recentLogins: number;
|
||||
newUsersToday: number;
|
||||
userGrowth: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
roleDistribution: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
statusDistribution: {
|
||||
active: number;
|
||||
suspended: number;
|
||||
deleted: number;
|
||||
};
|
||||
activityTimeline: {
|
||||
date: string;
|
||||
newUsers: number;
|
||||
logins: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type GetDashboardStatsInput = {
|
||||
actorId: string;
|
||||
};
|
||||
|
||||
export type GetDashboardStatsErrorCode = 'AUTHORIZATION_ERROR' | 'REPOSITORY_ERROR';
|
||||
|
||||
export type GetDashboardStatsApplicationError = ApplicationErrorCode<GetDashboardStatsErrorCode, { message: string; details?: unknown }>;
|
||||
|
||||
export class GetDashboardStatsUseCase {
|
||||
constructor(
|
||||
private readonly adminUserRepo: IAdminUserRepository,
|
||||
private readonly output: UseCaseOutputPort<DashboardStatsResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: GetDashboardStatsInput): Promise<Result<void, GetDashboardStatsApplicationError>> {
|
||||
try {
|
||||
// Get actor (current user)
|
||||
const actor = await this.adminUserRepo.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 view dashboard' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get all users
|
||||
const allUsersResult = await this.adminUserRepo.list();
|
||||
const allUsers = allUsersResult.users;
|
||||
|
||||
// Calculate basic stats
|
||||
const totalUsers = allUsers.length;
|
||||
const activeUsers = allUsers.filter(u => u.status.value === 'active').length;
|
||||
const suspendedUsers = allUsers.filter(u => u.status.value === 'suspended').length;
|
||||
const deletedUsers = allUsers.filter(u => u.status.value === 'deleted').length;
|
||||
const systemAdmins = allUsers.filter(u => u.isSystemAdmin()).length;
|
||||
|
||||
// Recent logins (last 24 hours)
|
||||
const oneDayAgo = new Date();
|
||||
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
|
||||
const recentLogins = allUsers.filter(u => u.lastLoginAt && u.lastLoginAt > oneDayAgo).length;
|
||||
|
||||
// New users today
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const newUsersToday = allUsers.filter(u => u.createdAt > today).length;
|
||||
|
||||
// Role distribution
|
||||
const roleCounts: Record<string, number> = {};
|
||||
allUsers.forEach(user => {
|
||||
user.roles.forEach(role => {
|
||||
const roleValue = role.value;
|
||||
roleCounts[roleValue] = (roleCounts[roleValue] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
const roleDistribution = Object.entries(roleCounts).map(([role, count]) => ({
|
||||
label: role.charAt(0).toUpperCase() + role.slice(1),
|
||||
value: count,
|
||||
color: role === 'owner' ? 'text-purple-500' : role === 'admin' ? 'text-blue-500' : 'text-gray-500',
|
||||
}));
|
||||
|
||||
// User growth (last 7 days)
|
||||
const userGrowth: DashboardStatsResult['userGrowth'] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
|
||||
const count = allUsers.filter(u => {
|
||||
const userDate = new Date(u.createdAt);
|
||||
return userDate.toDateString() === date.toDateString();
|
||||
}).length;
|
||||
|
||||
userGrowth.push({
|
||||
label: dateStr,
|
||||
value: count,
|
||||
color: 'text-primary-blue',
|
||||
});
|
||||
}
|
||||
|
||||
// Activity timeline (last 7 days)
|
||||
const activityTimeline: DashboardStatsResult['activityTimeline'] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
|
||||
const newUsers = allUsers.filter(u => {
|
||||
const userDate = new Date(u.createdAt);
|
||||
return userDate.toDateString() === date.toDateString();
|
||||
}).length;
|
||||
|
||||
const logins = allUsers.filter(u => {
|
||||
const loginDate = u.lastLoginAt;
|
||||
return loginDate && loginDate.toDateString() === date.toDateString();
|
||||
}).length;
|
||||
|
||||
activityTimeline.push({
|
||||
date: dateStr,
|
||||
newUsers,
|
||||
logins,
|
||||
});
|
||||
}
|
||||
|
||||
const result: DashboardStatsResult = {
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
suspendedUsers,
|
||||
deletedUsers,
|
||||
systemAdmins,
|
||||
recentLogins,
|
||||
newUsersToday,
|
||||
userGrowth,
|
||||
roleDistribution,
|
||||
statusDistribution: {
|
||||
active: activeUsers,
|
||||
suspended: suspendedUsers,
|
||||
deleted: deletedUsers,
|
||||
},
|
||||
activityTimeline,
|
||||
};
|
||||
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get dashboard stats';
|
||||
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export class AuthenticatedUserDTO {
|
||||
primaryDriverId?: string;
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
avatarUrl?: string | null;
|
||||
@ApiProperty({ required: false, enum: ['driver', 'sponsor', 'league-owner', 'league-steward', 'league-admin', 'system-owner', 'super-admin'] })
|
||||
role?: 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin';
|
||||
}
|
||||
|
||||
export class AuthSessionDTO {
|
||||
|
||||
11
apps/api/src/persistence/admin/AdminPersistenceModule.ts
Normal file
11
apps/api/src/persistence/admin/AdminPersistenceModule.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Admin Persistence Module
|
||||
*
|
||||
* Abstract module interface for admin persistence.
|
||||
* Both InMemory and TypeORM implementations should export this.
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class AdminPersistenceModule {}
|
||||
7
apps/api/src/persistence/admin/AdminPersistenceTokens.ts
Normal file
7
apps/api/src/persistence/admin/AdminPersistenceTokens.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Admin Persistence Tokens
|
||||
*
|
||||
* Dependency injection tokens for admin persistence layer.
|
||||
*/
|
||||
|
||||
export const ADMIN_USER_REPOSITORY_TOKEN = 'IAdminUserRepository';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { InMemoryAdminUserRepository } from '@core/admin/infrastructure/persistence/InMemoryAdminUserRepository';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ADMIN_USER_REPOSITORY_TOKEN } from '../admin/AdminPersistenceTokens';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: ADMIN_USER_REPOSITORY_TOKEN,
|
||||
useClass: InMemoryAdminUserRepository,
|
||||
},
|
||||
],
|
||||
exports: [ADMIN_USER_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class InMemoryAdminPersistenceModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { AdminUserOrmEntity } from '@core/admin/infrastructure/typeorm/entities/AdminUserOrmEntity';
|
||||
import { TypeOrmAdminUserRepository } from '@core/admin/infrastructure/typeorm/repositories/TypeOrmAdminUserRepository';
|
||||
import { AdminUserOrmMapper } from '@core/admin/infrastructure/typeorm/mappers/AdminUserOrmMapper';
|
||||
|
||||
import { ADMIN_USER_REPOSITORY_TOKEN } from '../admin/AdminPersistenceTokens';
|
||||
|
||||
const typeOrmFeatureImports = [TypeOrmModule.forFeature([AdminUserOrmEntity])];
|
||||
|
||||
@Module({
|
||||
imports: [...typeOrmFeatureImports],
|
||||
providers: [
|
||||
{ provide: AdminUserOrmMapper, useFactory: () => new AdminUserOrmMapper() },
|
||||
{
|
||||
provide: ADMIN_USER_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource, mapper: AdminUserOrmMapper) =>
|
||||
new TypeOrmAdminUserRepository(dataSource, mapper),
|
||||
inject: [getDataSourceToken(), AdminUserOrmMapper],
|
||||
},
|
||||
],
|
||||
exports: [ADMIN_USER_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class PostgresAdminPersistenceModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { AdminUserOrmEntity } from '@core/admin/infrastructure/typeorm/entities/AdminUserOrmEntity';
|
||||
import { TypeOrmAdminUserRepository } from '@core/admin/infrastructure/typeorm/repositories/TypeOrmAdminUserRepository';
|
||||
import { AdminUserOrmMapper } from '@core/admin/infrastructure/typeorm/mappers/AdminUserOrmMapper';
|
||||
|
||||
import { ADMIN_USER_REPOSITORY_TOKEN } from '../admin/AdminPersistenceTokens';
|
||||
|
||||
const typeOrmFeatureImports = [TypeOrmModule.forFeature([AdminUserOrmEntity])];
|
||||
|
||||
@Module({
|
||||
imports: [...typeOrmFeatureImports],
|
||||
providers: [
|
||||
{ provide: AdminUserOrmMapper, useFactory: () => new AdminUserOrmMapper() },
|
||||
{
|
||||
provide: ADMIN_USER_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource, mapper: AdminUserOrmMapper) =>
|
||||
new TypeOrmAdminUserRepository(dataSource, mapper),
|
||||
inject: [getDataSourceToken(), AdminUserOrmMapper],
|
||||
},
|
||||
],
|
||||
exports: [ADMIN_USER_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class TypeOrmAdminPersistenceModule {}
|
||||
Reference in New Issue
Block a user