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

@@ -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 {

View 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);
}
}

View 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 {}

View 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',
})
);
});
});
});
});

View 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;
}
}

View 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);
}

View 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[];
}

View 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');
});
});
});

View 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';
}

View 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);
});
});
});

View 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;
}

View File

@@ -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;
}
}

View 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);
}
});
});
});

View 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;
}
}

View 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 },
});
}
}
}

View File

@@ -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 {

View 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 {}

View File

@@ -0,0 +1,7 @@
/**
* Admin Persistence Tokens
*
* Dependency injection tokens for admin persistence layer.
*/
export const ADMIN_USER_REPOSITORY_TOKEN = 'IAdminUserRepository';

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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 {}