diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts new file mode 100644 index 0000000..0397b40 --- /dev/null +++ b/src/admin/admin.module.ts @@ -0,0 +1,40 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminAuditLog } from './entities/admin-audit-log.entity'; +import { AdminAuditLogService } from './services/admin-audit-log.service'; +import { AdminUsersService } from './services/admin-users.service'; +import { AdminPuzzlesController } from './controllers/admin-puzzles.controller'; +import { AdminUsersController } from './controllers/admin-users.controller'; +import { AdminAnalyticsController } from './controllers/admin-analytics.controller'; +import { AdminModerationController } from './controllers/admin-moderation.controller'; +import { AdminMonitoringController } from './controllers/admin-monitoring.controller'; +import { PuzzlesModule } from '../puzzles/puzzles.module'; +import { AuthModule } from '../auth/auth.module'; +import { AnalyticsModule } from '../analytics/analytics.module'; +import { User } from '../auth/entities/user.entity'; +import { Role } from '../auth/entities/role.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AdminAuditLog, User, Role]), + PuzzlesModule, + AuthModule, + AnalyticsModule, + ], + controllers: [ + AdminPuzzlesController, + AdminUsersController, + AdminAnalyticsController, + AdminModerationController, + AdminMonitoringController, + ], + providers: [ + AdminAuditLogService, + AdminUsersService, + ], + exports: [ + AdminAuditLogService, + AdminUsersService, + ], +}) +export class AdminModule { } diff --git a/src/admin/controllers/__tests__/admin-puzzles.controller.spec.ts b/src/admin/controllers/__tests__/admin-puzzles.controller.spec.ts new file mode 100644 index 0000000..ebe3fcd --- /dev/null +++ b/src/admin/controllers/__tests__/admin-puzzles.controller.spec.ts @@ -0,0 +1,71 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminPuzzlesController } from '../admin-puzzles.controller'; +import { PuzzlesService } from '../../../puzzles/puzzles.service'; +import { AdminAuditLogService } from '../../services/admin-audit-log.service'; +import { UserRole } from '../../../auth/constants'; + +describe('AdminPuzzlesController', () => { + let controller: AdminPuzzlesController; + let puzzlesService: PuzzlesService; + let auditLogService: AdminAuditLogService; + + const mockUser = { id: 'admin-id', role: { name: UserRole.ADMIN } }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminPuzzlesController], + providers: [ + { + provide: PuzzlesService, + useValue: { + findAll: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }, + }, + { + provide: AdminAuditLogService, + useValue: { + log: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(AdminPuzzlesController); + puzzlesService = module.get(PuzzlesService); + auditLogService = module.get(AdminAuditLogService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('findAll', () => { + it('should call puzzlesService.findAll', async () => { + const searchDto = {}; + await controller.findAll(searchDto); + expect(puzzlesService.findAll).toHaveBeenCalledWith(searchDto); + }); + }); + + describe('create', () => { + it('should call puzzlesService.create and auditLogService.log', async () => { + const createDto = { title: 'New Puzzle' } as any; + const mockPuzzle = { id: 'puzzle-id', title: 'New Puzzle' }; + (puzzlesService.create as jest.Mock).mockResolvedValue(mockPuzzle); + + await controller.create(createDto, mockUser); + + expect(puzzlesService.create).toHaveBeenCalledWith(createDto, mockUser.id); + expect(auditLogService.log).toHaveBeenCalledWith({ + adminId: mockUser.id, + action: 'CREATE_PUZZLE', + targetType: 'PUZZLE', + targetId: mockPuzzle.id, + details: { title: mockPuzzle.title }, + }); + }); + }); +}); diff --git a/src/admin/controllers/__tests__/admin-users.controller.spec.ts b/src/admin/controllers/__tests__/admin-users.controller.spec.ts new file mode 100644 index 0000000..b0a094e --- /dev/null +++ b/src/admin/controllers/__tests__/admin-users.controller.spec.ts @@ -0,0 +1,62 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminUsersController } from '../admin-users.controller'; +import { AdminUsersService } from '../../services/admin-users.service'; +import { AdminAuditLogService } from '../../services/admin-audit-log.service'; +import { UserRole } from '../../../auth/constants'; + +describe('AdminUsersController', () => { + let controller: AdminUsersController; + let adminUsersService: AdminUsersService; + let auditLogService: AdminAuditLogService; + + const mockAdmin = { id: 'admin-id', email: 'admin@test.com' }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminUsersController], + providers: [ + { + provide: AdminUsersService, + useValue: { + findAll: jest.fn(), + updateRole: jest.fn(), + updateStatus: jest.fn(), + }, + }, + { + provide: AdminAuditLogService, + useValue: { + log: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(AdminUsersController); + adminUsersService = module.get(AdminUsersService); + auditLogService = module.get(AdminAuditLogService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('updateRole', () => { + it('should call adminUsersService.updateRole and auditLogService.log', async () => { + const userId = 'user-uuid'; + const newRole = UserRole.MODERATOR; + (adminUsersService.updateRole as jest.Mock).mockResolvedValue({ id: userId, role: { name: newRole } }); + + await controller.updateRole(userId, newRole, mockAdmin); + + expect(adminUsersService.updateRole).toHaveBeenCalledWith(userId, newRole); + expect(auditLogService.log).toHaveBeenCalledWith({ + adminId: mockAdmin.id, + action: 'UPDATE_USER_ROLE', + targetType: 'USER', + targetId: userId, + details: { role: newRole }, + }); + }); + }); +}); diff --git a/src/admin/controllers/admin-analytics.controller.ts b/src/admin/controllers/admin-analytics.controller.ts new file mode 100644 index 0000000..70d4c8c --- /dev/null +++ b/src/admin/controllers/admin-analytics.controller.ts @@ -0,0 +1,30 @@ +import { + Controller, + Get, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../auth/constants'; +import { AnalyticsService } from '../../analytics/analytics.service'; +import { AnalyticsFilterDto } from '../../analytics/dto/analytics-filter.dto'; + +@Controller('admin/analytics') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AdminAnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) { } + + @Get('overview') + async getOverview(@Query() filter: AnalyticsFilterDto) { + return await this.analyticsService.getPlayersOverview(filter); + } + + @Get('active-players') + async getActivePlayers(@Query() filter: AnalyticsFilterDto) { + // Reusing getPlayersOverview as it gives total distinct players in period + return await this.analyticsService.getPlayersOverview(filter); + } +} diff --git a/src/admin/controllers/admin-moderation.controller.ts b/src/admin/controllers/admin-moderation.controller.ts new file mode 100644 index 0000000..e1722dc --- /dev/null +++ b/src/admin/controllers/admin-moderation.controller.ts @@ -0,0 +1,60 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../auth/constants'; +import { PuzzleModerationService } from '../../puzzles/services/puzzle-moderation.service'; +import { PuzzleSubmissionStatus } from '../../puzzles/entities/user-puzzle-submission.entity'; +import { ModerationDecisionDto } from '../../puzzles/dto/user-puzzle-submission.dto'; +import { AdminAuditLogService } from '../services/admin-audit-log.service'; +import { ActiveUser } from '../../auth/decorators/active-user.decorator'; + +@Controller('admin/moderation') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AdminModerationController { + constructor( + private readonly moderationService: PuzzleModerationService, + private readonly auditLogService: AdminAuditLogService, + ) { } + + @Get('queue') + async getQueue( + @Query('status') status?: PuzzleSubmissionStatus, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return await this.moderationService.getModerationQueue(status, page, limit); + } + + @Post(':id/moderate') + async moderate( + @Param('id', ParseUUIDPipe) id: string, + @Body() decision: ModerationDecisionDto, + @ActiveUser() admin: any, + ) { + const result = await this.moderationService.moderatePuzzle(id, admin.id, decision); + await this.auditLogService.log({ + adminId: admin.id, + action: 'MODERATE_PUZZLE', + targetType: 'PUZZLE_SUBMISSION', + targetId: id, + details: decision, + }); + return result; + } + + @Get('stats') + async getStats(@Query('timeframe') timeframe?: 'day' | 'week' | 'month') { + return await this.moderationService.getModerationStats(timeframe); + } +} diff --git a/src/admin/controllers/admin-monitoring.controller.ts b/src/admin/controllers/admin-monitoring.controller.ts new file mode 100644 index 0000000..377aae0 --- /dev/null +++ b/src/admin/controllers/admin-monitoring.controller.ts @@ -0,0 +1,40 @@ +import { + Controller, + Get, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../auth/constants'; +import { DatabaseService } from '../../config/database-service'; +import { PerformanceMonitoringService } from '../../monitoring/performance.service'; + +@Controller('admin/monitoring') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AdminMonitoringController { + private databaseService = DatabaseService.getInstance(); + private performanceService: PerformanceMonitoringService; + + constructor() { + this.performanceService = new PerformanceMonitoringService( + this.databaseService.getDataSource(), + ); + } + + @Get('health') + async checkHealth() { + return await this.databaseService.checkHealth(); + } + + @Get('metrics') + async getMetrics() { + return await this.performanceService.getMetrics(); + } + + @Get('db-stats') + async getDbStats() { + return await this.databaseService.getConnectionStats(); + } +} diff --git a/src/admin/controllers/admin-puzzles.controller.ts b/src/admin/controllers/admin-puzzles.controller.ts new file mode 100644 index 0000000..90f1bcc --- /dev/null +++ b/src/admin/controllers/admin-puzzles.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + ParseUUIDPipe, + HttpStatus, + HttpCode, + Req, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../auth/constants'; +import { PuzzlesService } from '../../puzzles/puzzles.service'; +import { CreatePuzzleDto, UpdatePuzzleDto, SearchPuzzleDto } from '../../puzzles/dto'; +import { AdminAuditLogService } from '../services/admin-audit-log.service'; +import { ActiveUser } from '../../auth/decorators/active-user.decorator'; + +@Controller('admin/puzzles') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AdminPuzzlesController { + constructor( + private readonly puzzlesService: PuzzlesService, + private readonly auditLogService: AdminAuditLogService, + ) { } + + @Get() + async findAll(@Body() searchDto: SearchPuzzleDto) { + return await this.puzzlesService.findAll(searchDto); + } + + @Post() + async create(@Body() createPuzzleDto: CreatePuzzleDto, @ActiveUser() user: any) { + const puzzle = await this.puzzlesService.create(createPuzzleDto, user.id); + await this.auditLogService.log({ + adminId: user.id, + action: 'CREATE_PUZZLE', + targetType: 'PUZZLE', + targetId: puzzle.id, + details: { title: puzzle.title }, + }); + return puzzle; + } + + @Patch(':id') + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updatePuzzleDto: UpdatePuzzleDto, + @ActiveUser() user: any, + ) { + const puzzle = await this.puzzlesService.update(id, updatePuzzleDto, user.id); + await this.auditLogService.log({ + adminId: user.id, + action: 'UPDATE_PUZZLE', + targetType: 'PUZZLE', + targetId: id, + details: updatePuzzleDto, + }); + return puzzle; + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', ParseUUIDPipe) id: string, @ActiveUser() user: any) { + await this.puzzlesService.remove(id, user.id); + await this.auditLogService.log({ + adminId: user.id, + action: 'DELETE_PUZZLE', + targetType: 'PUZZLE', + targetId: id, + }); + } +} diff --git a/src/admin/controllers/admin-users.controller.ts b/src/admin/controllers/admin-users.controller.ts new file mode 100644 index 0000000..978efe5 --- /dev/null +++ b/src/admin/controllers/admin-users.controller.ts @@ -0,0 +1,65 @@ +import { + Controller, + Get, + Body, + Patch, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../auth/constants'; +import { AdminUsersService } from '../services/admin-users.service'; +import { AdminAuditLogService } from '../services/admin-audit-log.service'; +import { ActiveUser } from '../../auth/decorators/active-user.decorator'; + +@Controller('admin/users') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AdminUsersController { + constructor( + private readonly adminUsersService: AdminUsersService, + private readonly auditLogService: AdminAuditLogService, + ) { } + + @Get() + async findAll() { + return await this.adminUsersService.findAll(); + } + + @Patch(':id/role') + async updateRole( + @Param('id', ParseUUIDPipe) id: string, + @Body('role') role: UserRole, + @ActiveUser() admin: any, + ) { + const user = await this.adminUsersService.updateRole(id, role); + await this.auditLogService.log({ + adminId: admin.id, + action: 'UPDATE_USER_ROLE', + targetType: 'USER', + targetId: id, + details: { role }, + }); + return user; + } + + @Patch(':id/status') + async updateStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body('isVerified') isVerified: boolean, + @ActiveUser() admin: any, + ) { + const user = await this.adminUsersService.updateStatus(id, isVerified); + await this.auditLogService.log({ + adminId: admin.id, + action: 'UPDATE_USER_STATUS', + targetType: 'USER', + targetId: id, + details: { isVerified }, + }); + return user; + } +} diff --git a/src/admin/entities/admin-audit-log.entity.ts b/src/admin/entities/admin-audit-log.entity.ts new file mode 100644 index 0000000..7a66b13 --- /dev/null +++ b/src/admin/entities/admin-audit-log.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; + +@Entity('admin_audit_logs') +export class AdminAuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, { eager: true }) + @JoinColumn({ name: 'adminId' }) + admin: User; + + @Column() + adminId: string; + + @Column() + action: string; + + @Column() + targetType: string; + + @Column({ nullable: true }) + targetId: string; + + @Column({ type: 'jsonb', nullable: true }) + details: any; + + @Column({ nullable: true }) + ipAddress: string; + + @Column({ nullable: true }) + userAgent: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/admin/services/admin-audit-log.service.ts b/src/admin/services/admin-audit-log.service.ts new file mode 100644 index 0000000..22fca63 --- /dev/null +++ b/src/admin/services/admin-audit-log.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AdminAuditLog } from '../entities/admin-audit-log.entity'; + +@Injectable() +export class AdminAuditLogService { + constructor( + @InjectRepository(AdminAuditLog) + private auditLogRepository: Repository, + ) { } + + async log(data: { + adminId: string; + action: string; + targetType: string; + targetId?: string; + details?: any; + ipAddress?: string; + userAgent?: string; + }): Promise { + const logEntry = this.auditLogRepository.create(data); + return await this.auditLogRepository.save(logEntry); + } + + async getLogs( + filters: { + adminId?: string; + action?: string; + targetType?: string; + startDate?: Date; + endDate?: Date; + }, + limit = 50, + offset = 0, + ): Promise<[AdminAuditLog[], number]> { + const query = this.auditLogRepository.createQueryBuilder('log') + .leftJoinAndSelect('log.admin', 'admin') + .orderBy('log.createdAt', 'DESC') + .take(limit) + .skip(offset); + + if (filters.adminId) { + query.andWhere('log.adminId = :adminId', { adminId: filters.adminId }); + } + + if (filters.action) { + query.andWhere('log.action = :action', { action: filters.action }); + } + + if (filters.targetType) { + query.andWhere('log.targetType = :targetType', { targetType: filters.targetType }); + } + + if (filters.startDate) { + query.andWhere('log.createdAt >= :startDate', { startDate: filters.startDate }); + } + + if (filters.endDate) { + query.andWhere('log.createdAt <= :endDate', { endDate: filters.endDate }); + } + + return await query.getManyAndCount(); + } +} diff --git a/src/admin/services/admin-users.service.ts b/src/admin/services/admin-users.service.ts new file mode 100644 index 0000000..9acc210 --- /dev/null +++ b/src/admin/services/admin-users.service.ts @@ -0,0 +1,48 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; +import { Role } from '../../auth/entities/role.entity'; +import { UserRole } from '../../auth/constants'; + +@Injectable() +export class AdminUsersService { + constructor( + @InjectRepository(User) + private usersRepository: Repository, + @InjectRepository(Role) + private rolesRepository: Repository, + ) { } + + async findAll() { + return await this.usersRepository.find({ + relations: ['role'], + order: { createdAt: 'DESC' }, + }); + } + + async updateRole(userId: string, roleName: UserRole) { + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + const role = await this.rolesRepository.findOne({ where: { name: roleName } }); + if (!role) { + throw new NotFoundException(`Role ${roleName} not found`); + } + + user.role = role; + return await this.usersRepository.save(user); + } + + async updateStatus(userId: string, isVerified: boolean) { + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + user.isVerified = isVerified; + return await this.usersRepository.save(user); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index c40b970..9b17da3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -42,6 +42,7 @@ import { QuestsModule } from './quests/quests.module'; import { IntegrationsModule } from './integrations/integrations.module'; import { BlockchainTransactionModule } from './blockchain-transaction/blockchain-transaction.module'; import { PrivacyModule } from './privacy/privacy.module'; +import { AdminModule } from './admin/admin.module'; import { LocalizationModule } from './common/i18n/localization.module'; import { DailyChallengesModule } from './daily-challenges/daily-challenges.module'; import { EnergyModule } from './energy/energy.module'; @@ -131,6 +132,7 @@ import { WalletAuthModule } from './auth/wallet-auth.module'; IntegrationsModule, BlockchainTransactionModule, PrivacyModule, + AdminModule, LocalizationModule, DailyChallengesModule, SkillRatingModule, diff --git a/src/auth/decorators/active-user.decorator.ts b/src/auth/decorators/active-user.decorator.ts new file mode 100644 index 0000000..e69e2fd --- /dev/null +++ b/src/auth/decorators/active-user.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const ActiveUser = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; + }, +); diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts index 6c1572f..ce29a43 100644 --- a/src/auth/guards/roles.guard.ts +++ b/src/auth/guards/roles.guard.ts @@ -1,11 +1,11 @@ import { Injectable, type CanActivate, type ExecutionContext } from "@nestjs/common" -import type { Reflector } from "@nestjs/core" +import { Reflector } from "@nestjs/core" import { ROLES_KEY, type UserRole } from "../constants" import type { RequestWithUser } from "../interfaces/request-with-user.interface" @Injectable() export class RolesGuard implements CanActivate { - constructor(private reflector: Reflector) {} + constructor(private reflector: Reflector) { } canActivate(context: ExecutionContext): boolean { const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ diff --git a/src/puzzles/puzzles.module.ts b/src/puzzles/puzzles.module.ts index 89b68ff..2a4db88 100644 --- a/src/puzzles/puzzles.module.ts +++ b/src/puzzles/puzzles.module.ts @@ -43,6 +43,6 @@ import { LocalizationModule } from '../common/i18n/localization.module'; CollectionsService, ThemesService // Add ThemesService ], - exports: [PuzzlesService] + exports: [PuzzlesService, PuzzleModerationService] }) export class PuzzlesModule { } \ No newline at end of file