From c69a77b99703c45292601c58af5f87c9d2f81534 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Tue, 6 Jan 2026 23:27:50 +0300 Subject: [PATCH] backend admin and room --- backend/src/admin/admin.module.ts | 70 ++++ .../analytics/admin-analytics.controller.ts | 38 ++ .../analytics/admin-analytics.service.ts | 202 +++++++++++ .../src/admin/analytics/dto/date-range.dto.ts | 11 + .../src/admin/auth/admin-auth.controller.ts | 61 ++++ backend/src/admin/auth/admin-auth.service.ts | 328 ++++++++++++++++++ .../src/admin/auth/dto/refresh-token.dto.ts | 6 + .../src/admin/auth/dto/request-code.dto.ts | 3 + backend/src/admin/auth/dto/verify-code.dto.ts | 7 + .../admin-game-history.controller.ts | 34 ++ .../admin-game-history.service.ts | 79 +++++ backend/src/admin/guards/admin-auth.guard.ts | 38 ++ backend/src/admin/guards/admin.guard.ts | 49 +++ .../src/admin/packs/admin-packs.controller.ts | 49 +++ .../src/admin/packs/admin-packs.service.ts | 160 +++++++++ .../src/admin/packs/dto/create-pack.dto.ts | 19 + .../src/admin/packs/dto/pack-filters.dto.ts | 29 ++ .../src/admin/packs/dto/update-pack.dto.ts | 23 ++ .../src/admin/rooms/admin-rooms.controller.ts | 33 ++ .../src/admin/rooms/admin-rooms.service.ts | 131 +++++++ .../src/admin/rooms/dto/room-filters.dto.ts | 28 ++ .../src/admin/users/admin-users.controller.ts | 41 +++ .../src/admin/users/admin-users.service.ts | 128 +++++++ .../src/admin/users/dto/update-user.dto.ts | 15 + .../src/admin/users/dto/user-filters.dto.ts | 24 ++ backend/src/admin/utils/admin-checker.util.ts | 82 +++++ src/components/ThemeSwitcher.css | 7 + src/hooks/useRoom.js | 17 + src/pages/CreateRoom.jsx | 18 +- src/pages/JoinRoom.jsx | 189 +++++++--- src/pages/RoomPage.jsx | 119 ++++++- src/services/api.js | 2 + 32 files changed, 1980 insertions(+), 60 deletions(-) create mode 100644 backend/src/admin/admin.module.ts create mode 100644 backend/src/admin/analytics/admin-analytics.controller.ts create mode 100644 backend/src/admin/analytics/admin-analytics.service.ts create mode 100644 backend/src/admin/analytics/dto/date-range.dto.ts create mode 100644 backend/src/admin/auth/admin-auth.controller.ts create mode 100644 backend/src/admin/auth/admin-auth.service.ts create mode 100644 backend/src/admin/auth/dto/refresh-token.dto.ts create mode 100644 backend/src/admin/auth/dto/request-code.dto.ts create mode 100644 backend/src/admin/auth/dto/verify-code.dto.ts create mode 100644 backend/src/admin/game-history/admin-game-history.controller.ts create mode 100644 backend/src/admin/game-history/admin-game-history.service.ts create mode 100644 backend/src/admin/guards/admin-auth.guard.ts create mode 100644 backend/src/admin/guards/admin.guard.ts create mode 100644 backend/src/admin/packs/admin-packs.controller.ts create mode 100644 backend/src/admin/packs/admin-packs.service.ts create mode 100644 backend/src/admin/packs/dto/create-pack.dto.ts create mode 100644 backend/src/admin/packs/dto/pack-filters.dto.ts create mode 100644 backend/src/admin/packs/dto/update-pack.dto.ts create mode 100644 backend/src/admin/rooms/admin-rooms.controller.ts create mode 100644 backend/src/admin/rooms/admin-rooms.service.ts create mode 100644 backend/src/admin/rooms/dto/room-filters.dto.ts create mode 100644 backend/src/admin/users/admin-users.controller.ts create mode 100644 backend/src/admin/users/admin-users.service.ts create mode 100644 backend/src/admin/users/dto/update-user.dto.ts create mode 100644 backend/src/admin/users/dto/user-filters.dto.ts create mode 100644 backend/src/admin/utils/admin-checker.util.ts diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts new file mode 100644 index 0000000..f4c3aaa --- /dev/null +++ b/backend/src/admin/admin.module.ts @@ -0,0 +1,70 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { PrismaModule } from '../prisma/prisma.module'; + +// Auth +import { AdminAuthController } from './auth/admin-auth.controller'; +import { AdminAuthService } from './auth/admin-auth.service'; + +// Users +import { AdminUsersController } from './users/admin-users.controller'; +import { AdminUsersService } from './users/admin-users.service'; + +// Rooms +import { AdminRoomsController } from './rooms/admin-rooms.controller'; +import { AdminRoomsService } from './rooms/admin-rooms.service'; + +// Packs +import { AdminPacksController } from './packs/admin-packs.controller'; +import { AdminPacksService } from './packs/admin-packs.service'; + +// Analytics +import { AdminAnalyticsController } from './analytics/admin-analytics.controller'; +import { AdminAnalyticsService } from './analytics/admin-analytics.service'; + +// Game History +import { AdminGameHistoryController } from './game-history/admin-game-history.controller'; +import { AdminGameHistoryService } from './game-history/admin-game-history.service'; + +// Guards +import { AdminAuthGuard } from './guards/admin-auth.guard'; +import { AdminGuard } from './guards/admin.guard'; + +@Module({ + imports: [ + PrismaModule, + ConfigModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: + configService.get('ADMIN_JWT_SECRET') || + configService.get('JWT_SECRET') || + 'admin-secret-key', + signOptions: { expiresIn: '1h' }, + }), + inject: [ConfigService], + }), + ], + controllers: [ + AdminAuthController, + AdminUsersController, + AdminRoomsController, + AdminPacksController, + AdminAnalyticsController, + AdminGameHistoryController, + ], + providers: [ + AdminAuthService, + AdminUsersService, + AdminRoomsService, + AdminPacksService, + AdminAnalyticsService, + AdminGameHistoryService, + AdminAuthGuard, + AdminGuard, + ], + exports: [AdminAuthService], +}) +export class AdminModule {} diff --git a/backend/src/admin/analytics/admin-analytics.controller.ts b/backend/src/admin/analytics/admin-analytics.controller.ts new file mode 100644 index 0000000..c89a2bf --- /dev/null +++ b/backend/src/admin/analytics/admin-analytics.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { AdminAnalyticsService } from './admin-analytics.service'; +import { DateRangeDto } from './dto/date-range.dto'; +import { AdminAuthGuard } from '../guards/admin-auth.guard'; +import { AdminGuard } from '../guards/admin.guard'; + +@Controller('api/admin/analytics') +@UseGuards(AdminAuthGuard, AdminGuard) +export class AdminAnalyticsController { + constructor( + private readonly adminAnalyticsService: AdminAnalyticsService, + ) {} + + @Get('dashboard') + getDashboardStats() { + return this.adminAnalyticsService.getDashboardStats(); + } + + @Get('user-activity') + getUserActivity(@Query() dateRangeDto: DateRangeDto) { + return this.adminAnalyticsService.getUserActivity(dateRangeDto); + } + + @Get('popular-packs') + getPopularPacks() { + return this.adminAnalyticsService.getPopularPacks(); + } + + @Get('game-stats') + getGameStats(@Query() dateRangeDto: DateRangeDto) { + return this.adminAnalyticsService.getGameStats(dateRangeDto); + } + + @Get('recent-rooms') + getRecentRooms() { + return this.adminAnalyticsService.getRecentRooms(); + } +} diff --git a/backend/src/admin/analytics/admin-analytics.service.ts b/backend/src/admin/analytics/admin-analytics.service.ts new file mode 100644 index 0000000..32a7b60 --- /dev/null +++ b/backend/src/admin/analytics/admin-analytics.service.ts @@ -0,0 +1,202 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { DateRangeDto } from './dto/date-range.dto'; + +@Injectable() +export class AdminAnalyticsService { + constructor(private prisma: PrismaService) {} + + async getDashboardStats() { + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const [ + totalUsers, + activeUsers, + totalRooms, + activeRooms, + totalPacks, + publicPacks, + totalGames, + gamesToday, + ] = await Promise.all([ + this.prisma.user.count(), + this.prisma.user.count({ + where: { + participants: { + some: { + joinedAt: { + gte: sevenDaysAgo, + }, + }, + }, + }, + }), + this.prisma.room.count(), + this.prisma.room.count({ + where: { + status: { + in: ['WAITING', 'PLAYING'], + }, + }, + }), + this.prisma.questionPack.count(), + this.prisma.questionPack.count({ + where: { isPublic: true }, + }), + this.prisma.gameHistory.count(), + this.prisma.gameHistory.count({ + where: { + finishedAt: { + gte: today, + }, + }, + }), + ]); + + return { + users: totalUsers, + activeUsers, + rooms: totalRooms, + activeRooms, + questionPacks: totalPacks, + publicPacks, + gamesPlayed: totalGames, + gamesToday, + }; + } + + async getUserActivity(dateRangeDto: DateRangeDto) { + const { dateFrom, dateTo } = dateRangeDto; + + // Default to last 30 days if no range provided + const endDate = dateTo ? new Date(dateTo) : new Date(); + const startDate = dateFrom + ? new Date(dateFrom) + : new Date(endDate.getTime() - 30 * 24 * 60 * 60 * 1000); + + // Get user registrations per day + const users = await this.prisma.user.findMany({ + where: { + createdAt: { + gte: startDate, + lte: endDate, + }, + }, + select: { + createdAt: true, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + // Group by date + const activityMap = new Map(); + users.forEach((user) => { + const date = user.createdAt.toISOString().split('T')[0]; + activityMap.set(date, (activityMap.get(date) || 0) + 1); + }); + + // Convert to array format + const activity = Array.from(activityMap.entries()).map(([date, count]) => ({ + date, + count, + })); + + return { activity }; + } + + async getPopularPacks() { + const packs = await this.prisma.questionPack.findMany({ + take: 10, + select: { + id: true, + name: true, + category: true, + timesUsed: true, + rating: true, + questionCount: true, + }, + orderBy: { + timesUsed: 'desc', + }, + }); + + return { packs }; + } + + async getGameStats(dateRangeDto: DateRangeDto) { + const { dateFrom, dateTo } = dateRangeDto; + + const where: any = {}; + if (dateFrom || dateTo) { + where.finishedAt = {}; + if (dateFrom) { + where.finishedAt.gte = new Date(dateFrom); + } + if (dateTo) { + where.finishedAt.lte = new Date(dateTo); + } + } + + const games = await this.prisma.gameHistory.findMany({ + where, + select: { + startedAt: true, + finishedAt: true, + }, + }); + + const totalGames = games.length; + const totalDuration = games.reduce((sum, game) => { + const duration = + new Date(game.finishedAt).getTime() - + new Date(game.startedAt).getTime(); + return sum + duration; + }, 0); + + const avgDuration = + totalGames > 0 ? Math.floor(totalDuration / totalGames / 1000 / 60) : 0; // in minutes + + return { + totalGames, + avgDuration, // in minutes + }; + } + + async getRecentRooms() { + const rooms = await this.prisma.room.findMany({ + take: 10, + select: { + id: true, + code: true, + status: true, + createdAt: true, + host: { + select: { + name: true, + }, + }, + questionPack: { + select: { + name: true, + }, + }, + _count: { + select: { + participants: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return { rooms }; + } +} diff --git a/backend/src/admin/analytics/dto/date-range.dto.ts b/backend/src/admin/analytics/dto/date-range.dto.ts new file mode 100644 index 0000000..3add16c --- /dev/null +++ b/backend/src/admin/analytics/dto/date-range.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsDateString } from 'class-validator'; + +export class DateRangeDto { + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; +} diff --git a/backend/src/admin/auth/admin-auth.controller.ts b/backend/src/admin/auth/admin-auth.controller.ts new file mode 100644 index 0000000..837f92d --- /dev/null +++ b/backend/src/admin/auth/admin-auth.controller.ts @@ -0,0 +1,61 @@ +import { + Controller, + Post, + Get, + Body, + Param, + UseGuards, + Request, +} from '@nestjs/common'; +import { AdminAuthService } from './admin-auth.service'; +import { VerifyCodeDto } from './dto/verify-code.dto'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; +import { AdminAuthGuard } from '../guards/admin-auth.guard'; +import { AdminGuard } from '../guards/admin.guard'; + +@Controller('api/admin/auth') +export class AdminAuthController { + constructor(private readonly adminAuthService: AdminAuthService) {} + + @Post('request-code') + async requestCode() { + return this.adminAuthService.generateCode(); + } + + @Get('code-status/:code') + async getCodeStatus(@Param('code') code: string) { + return this.adminAuthService.getCodeStatus(code); + } + + @Post('verify-code') + async verifyCode(@Body() verifyCodeDto: VerifyCodeDto) { + return this.adminAuthService.verifyCode(verifyCodeDto.code); + } + + @Post('refresh') + async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) { + return this.adminAuthService.refreshAccessToken( + refreshTokenDto.refreshToken, + ); + } + + @Get('me') + @UseGuards(AdminAuthGuard) + async getCurrentUser(@Request() req) { + return this.adminAuthService.getCurrentUser(req.user.sub); + } + + @Post('sync-admins') + @UseGuards(AdminAuthGuard, AdminGuard) + async syncAdmins() { + return this.adminAuthService.syncAdmins(); + } + + @Get('admin-ids') + @UseGuards(AdminAuthGuard, AdminGuard) + async getAdminIds() { + return { + adminIds: this.adminAuthService.getAdminIdsList(), + }; + } +} diff --git a/backend/src/admin/auth/admin-auth.service.ts b/backend/src/admin/auth/admin-auth.service.ts new file mode 100644 index 0000000..f84e5c1 --- /dev/null +++ b/backend/src/admin/auth/admin-auth.service.ts @@ -0,0 +1,328 @@ +import { Injectable, UnauthorizedException, OnModuleInit } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CodeStatus } from '@prisma/client'; +import { AdminChecker } from '../utils/admin-checker.util'; + +@Injectable() +export class AdminAuthService implements OnModuleInit { + constructor( + private prisma: PrismaService, + private jwtService: JwtService, + private configService: ConfigService, + ) {} + + async onModuleInit() { + // Initialize AdminChecker with environment variables + AdminChecker.initialize(this.configService); + + // Sync admin users from ADMIN_IDS to database + await this.syncAdminUsers(); + } + + /** + * Sync admin users from ADMIN_IDS environment variable to database + */ + private async syncAdminUsers(): Promise { + const adminIds = AdminChecker.getAdminIds(); + + for (const telegramId of adminIds) { + // Check if user exists + let user = await this.prisma.user.findUnique({ + where: { telegramId }, + }); + + if (!user) { + // Create new admin user + user = await this.prisma.user.create({ + data: { + telegramId, + role: 'ADMIN', + name: `Admin ${telegramId}`, + }, + }); + } else if (user.role !== 'ADMIN') { + // Upgrade existing user to admin + await this.prisma.user.update({ + where: { id: user.id }, + data: { role: 'ADMIN' }, + }); + } + } + } + + async generateCode(): Promise<{ code: string }> { + // Generate 6-digit code + const code = Math.floor(100000 + Math.random() * 900000).toString(); + + // Set expiration to 10 minutes from now + const expiresAt = new Date(); + expiresAt.setMinutes(expiresAt.getMinutes() + 10); + + // Check if code already exists (unlikely but possible) + const existing = await this.prisma.adminAuthCode.findUnique({ + where: { code }, + }); + + if (existing) { + // Recursively generate new code if collision + return this.generateCode(); + } + + // Create code in database + await this.prisma.adminAuthCode.create({ + data: { + code, + status: CodeStatus.PENDING, + expiresAt, + }, + }); + + return { code }; + } + + async getCodeStatus(code: string): Promise<{ + status: CodeStatus; + expiresAt?: string; + }> { + const authCode = await this.prisma.adminAuthCode.findUnique({ + where: { code }, + }); + + if (!authCode) { + throw new UnauthorizedException('Invalid code'); + } + + // Check if code is expired + if (new Date() > authCode.expiresAt) { + // Update status to EXPIRED + await this.prisma.adminAuthCode.update({ + where: { code }, + data: { status: CodeStatus.EXPIRED }, + }); + return { status: CodeStatus.EXPIRED }; + } + + return { + status: authCode.status, + expiresAt: authCode.expiresAt.toISOString(), + }; + } + + async claimCode(code: string, telegramId: string): Promise { + // Check if telegramId is in the admin list + if (!AdminChecker.isAdmin(telegramId)) { + throw new UnauthorizedException('Telegram ID not authorized as admin'); + } + + const authCode = await this.prisma.adminAuthCode.findUnique({ + where: { code }, + }); + + if (!authCode) { + throw new UnauthorizedException('Invalid code'); + } + + if (new Date() > authCode.expiresAt) { + throw new UnauthorizedException('Code expired'); + } + + if (authCode.status !== CodeStatus.PENDING) { + throw new UnauthorizedException('Code already used'); + } + + // Ensure user exists in database with ADMIN role + let user = await this.prisma.user.findUnique({ + where: { telegramId }, + }); + + if (!user) { + user = await this.prisma.user.create({ + data: { + telegramId, + role: 'ADMIN', + name: `Admin ${telegramId}`, + }, + }); + } else if (user.role !== 'ADMIN') { + await this.prisma.user.update({ + where: { id: user.id }, + data: { role: 'ADMIN' }, + }); + } + + // Update code status to CLAIMED + await this.prisma.adminAuthCode.update({ + where: { code }, + data: { + telegramId, + status: CodeStatus.CLAIMED, + }, + }); + } + + async verifyCode(code: string): Promise<{ + token: string; + refreshToken: string; + expiresIn: number; + user: any; + }> { + const authCode = await this.prisma.adminAuthCode.findUnique({ + where: { code }, + }); + + if (!authCode) { + throw new UnauthorizedException('Invalid code'); + } + + if (new Date() > authCode.expiresAt) { + throw new UnauthorizedException('Code expired'); + } + + if (authCode.status !== CodeStatus.CLAIMED) { + throw new UnauthorizedException('Code not claimed by Telegram user'); + } + + if (!authCode.telegramId) { + throw new UnauthorizedException('No Telegram ID associated with code'); + } + + // Find user by telegramId + const user = await this.prisma.user.findUnique({ + where: { telegramId: authCode.telegramId }, + select: { + id: true, + email: true, + name: true, + role: true, + telegramId: true, + }, + }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + if (user.role !== 'ADMIN') { + throw new UnauthorizedException('User is not an admin'); + } + + // Mark code as USED + await this.prisma.adminAuthCode.update({ + where: { code }, + data: { + status: CodeStatus.USED, + usedAt: new Date(), + }, + }); + + // Generate tokens + const expiresIn = 3600; // 1 hour + const secret = this.configService.get('ADMIN_JWT_SECRET') || + this.configService.get('JWT_SECRET'); + + const token = this.jwtService.sign( + { sub: user.id, email: user.email, role: user.role }, + { secret, expiresIn }, + ); + + const refreshToken = this.jwtService.sign( + { sub: user.id, type: 'refresh' }, + { secret, expiresIn: 60 * 60 * 24 * 7 }, // 7 days + ); + + return { + token, + refreshToken, + expiresIn, + user, + }; + } + + async refreshAccessToken(refreshToken: string): Promise<{ + token: string; + refreshToken: string; + expiresIn: number; + }> { + try { + const secret = this.configService.get('ADMIN_JWT_SECRET') || + this.configService.get('JWT_SECRET'); + const payload = await this.jwtService.verifyAsync(refreshToken, { secret }); + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid refresh token'); + } + + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: { id: true, email: true, role: true }, + }); + + if (!user || user.role !== 'ADMIN') { + throw new UnauthorizedException('User not found or not admin'); + } + + const expiresIn = 3600; // 1 hour + const newToken = this.jwtService.sign( + { sub: user.id, email: user.email, role: user.role }, + { secret, expiresIn }, + ); + + const newRefreshToken = this.jwtService.sign( + { sub: user.id, type: 'refresh' }, + { secret, expiresIn: 60 * 60 * 24 * 7 }, // 7 days + ); + + return { + token: newToken, + refreshToken: newRefreshToken, + expiresIn, + }; + } catch { + throw new UnauthorizedException('Invalid refresh token'); + } + } + + async getCurrentUser(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + role: true, + telegramId: true, + createdAt: true, + }, + }); + + if (!user || user.role !== 'ADMIN') { + throw new UnauthorizedException('User not found or not admin'); + } + + return user; + } + + /** + * Manually trigger sync of admin users from ADMIN_IDS + */ + async syncAdmins(): Promise<{ + synced: number; + adminIds: string[]; + }> { + await this.syncAdminUsers(); + + return { + synced: AdminChecker.getAdminIds().length, + adminIds: AdminChecker.getAdminIds(), + }; + } + + /** + * Get list of all admin IDs from environment + */ + getAdminIdsList(): string[] { + return AdminChecker.getAdminIds(); + } +} diff --git a/backend/src/admin/auth/dto/refresh-token.dto.ts b/backend/src/admin/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..3c56e21 --- /dev/null +++ b/backend/src/admin/auth/dto/refresh-token.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class RefreshTokenDto { + @IsString() + refreshToken: string; +} diff --git a/backend/src/admin/auth/dto/request-code.dto.ts b/backend/src/admin/auth/dto/request-code.dto.ts new file mode 100644 index 0000000..132e54f --- /dev/null +++ b/backend/src/admin/auth/dto/request-code.dto.ts @@ -0,0 +1,3 @@ +export class RequestCodeDto { + // Empty - no request body needed for code generation +} diff --git a/backend/src/admin/auth/dto/verify-code.dto.ts b/backend/src/admin/auth/dto/verify-code.dto.ts new file mode 100644 index 0000000..7d313ef --- /dev/null +++ b/backend/src/admin/auth/dto/verify-code.dto.ts @@ -0,0 +1,7 @@ +import { IsString, Length } from 'class-validator'; + +export class VerifyCodeDto { + @IsString() + @Length(6, 6) + code: string; +} diff --git a/backend/src/admin/game-history/admin-game-history.controller.ts b/backend/src/admin/game-history/admin-game-history.controller.ts new file mode 100644 index 0000000..9d63039 --- /dev/null +++ b/backend/src/admin/game-history/admin-game-history.controller.ts @@ -0,0 +1,34 @@ +import { + Controller, + Get, + Delete, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { AdminGameHistoryService } from './admin-game-history.service'; +import { AdminAuthGuard } from '../guards/admin-auth.guard'; +import { AdminGuard } from '../guards/admin.guard'; + +@Controller('api/admin/game-history') +@UseGuards(AdminAuthGuard, AdminGuard) +export class AdminGameHistoryController { + constructor( + private readonly adminGameHistoryService: AdminGameHistoryService, + ) {} + + @Get() + findAll(@Query('page') page?: number, @Query('limit') limit?: number) { + return this.adminGameHistoryService.findAll(page, limit); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.adminGameHistoryService.findOne(id); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.adminGameHistoryService.remove(id); + } +} diff --git a/backend/src/admin/game-history/admin-game-history.service.ts b/backend/src/admin/game-history/admin-game-history.service.ts new file mode 100644 index 0000000..b1b056f --- /dev/null +++ b/backend/src/admin/game-history/admin-game-history.service.ts @@ -0,0 +1,79 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class AdminGameHistoryService { + constructor(private prisma: PrismaService) {} + + async findAll(page: number = 1, limit: number = 10) { + const skip = (page - 1) * limit; + + const [games, total] = await Promise.all([ + this.prisma.gameHistory.findMany({ + skip, + take: limit, + select: { + id: true, + roomCode: true, + questionPackId: true, + startedAt: true, + finishedAt: true, + players: true, + }, + orderBy: { + finishedAt: 'desc', + }, + }), + this.prisma.gameHistory.count(), + ]); + + return { + games, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(id: string) { + const game = await this.prisma.gameHistory.findUnique({ + where: { id }, + include: { + room: { + select: { + code: true, + host: { + select: { + name: true, + email: true, + }, + }, + }, + }, + }, + }); + + if (!game) { + throw new NotFoundException('Game history not found'); + } + + return game; + } + + async remove(id: string) { + const game = await this.prisma.gameHistory.findUnique({ + where: { id }, + }); + + if (!game) { + throw new NotFoundException('Game history not found'); + } + + await this.prisma.gameHistory.delete({ + where: { id }, + }); + + return { message: 'Game history deleted successfully' }; + } +} diff --git a/backend/src/admin/guards/admin-auth.guard.ts b/backend/src/admin/guards/admin-auth.guard.ts new file mode 100644 index 0000000..54d8e97 --- /dev/null +++ b/backend/src/admin/guards/admin-auth.guard.ts @@ -0,0 +1,38 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class AdminAuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new UnauthorizedException('No token provided'); + } + + const token = authHeader.substring(7); + + try { + const secret = + this.configService.get('ADMIN_JWT_SECRET') || + this.configService.get('JWT_SECRET'); + const payload = await this.jwtService.verifyAsync(token, { secret }); + request.user = payload; + return true; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } +} diff --git a/backend/src/admin/guards/admin.guard.ts b/backend/src/admin/guards/admin.guard.ts new file mode 100644 index 0000000..cb42c7e --- /dev/null +++ b/backend/src/admin/guards/admin.guard.ts @@ -0,0 +1,49 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { AdminChecker } from '../utils/admin-checker.util'; + +@Injectable() +export class AdminGuard implements CanActivate { + constructor(private prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user || !user.sub) { + throw new ForbiddenException('User not authenticated'); + } + + const dbUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { role: true, telegramId: true }, + }); + + if (!dbUser) { + throw new ForbiddenException('User not found'); + } + + // Check if user has ADMIN role OR is in ADMIN_IDS list + const isAdminRole = dbUser.role === 'ADMIN'; + const isInAdminList = AdminChecker.isAdmin(dbUser.telegramId); + + if (!isAdminRole && !isInAdminList) { + throw new ForbiddenException('Admin access required'); + } + + // If user is in ADMIN_IDS but doesn't have ADMIN role, upgrade them + if (isInAdminList && !isAdminRole) { + await this.prisma.user.update({ + where: { id: user.sub }, + data: { role: 'ADMIN' }, + }); + } + + return true; + } +} diff --git a/backend/src/admin/packs/admin-packs.controller.ts b/backend/src/admin/packs/admin-packs.controller.ts new file mode 100644 index 0000000..c279083 --- /dev/null +++ b/backend/src/admin/packs/admin-packs.controller.ts @@ -0,0 +1,49 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Query, + Body, + UseGuards, + Request, +} from '@nestjs/common'; +import { AdminPacksService } from './admin-packs.service'; +import { PackFiltersDto } from './dto/pack-filters.dto'; +import { CreatePackDto } from './dto/create-pack.dto'; +import { UpdatePackDto } from './dto/update-pack.dto'; +import { AdminAuthGuard } from '../guards/admin-auth.guard'; +import { AdminGuard } from '../guards/admin.guard'; + +@Controller('api/admin/packs') +@UseGuards(AdminAuthGuard, AdminGuard) +export class AdminPacksController { + constructor(private readonly adminPacksService: AdminPacksService) {} + + @Get() + findAll(@Query() filters: PackFiltersDto) { + return this.adminPacksService.findAll(filters); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.adminPacksService.findOne(id); + } + + @Post() + create(@Body() createPackDto: CreatePackDto, @Request() req) { + return this.adminPacksService.create(createPackDto, req.user.sub); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updatePackDto: UpdatePackDto) { + return this.adminPacksService.update(id, updatePackDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.adminPacksService.remove(id); + } +} diff --git a/backend/src/admin/packs/admin-packs.service.ts b/backend/src/admin/packs/admin-packs.service.ts new file mode 100644 index 0000000..6a0fd39 --- /dev/null +++ b/backend/src/admin/packs/admin-packs.service.ts @@ -0,0 +1,160 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { PackFiltersDto } from './dto/pack-filters.dto'; +import { CreatePackDto } from './dto/create-pack.dto'; +import { UpdatePackDto } from './dto/update-pack.dto'; + +@Injectable() +export class AdminPacksService { + constructor(private prisma: PrismaService) {} + + async findAll(filters: PackFiltersDto) { + const { search, category, isPublic, page = 1, limit = 10 } = filters; + const skip = (page - 1) * limit; + + const where: any = {}; + + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + ]; + } + + if (category) { + where.category = category; + } + + if (isPublic !== undefined) { + where.isPublic = isPublic; + } + + const [packs, total] = await Promise.all([ + this.prisma.questionPack.findMany({ + where, + skip, + take: limit, + select: { + id: true, + name: true, + description: true, + category: true, + isPublic: true, + questionCount: true, + timesUsed: true, + rating: true, + createdAt: true, + updatedAt: true, + creator: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.questionPack.count({ where }), + ]); + + return { + packs, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(id: string) { + const pack = await this.prisma.questionPack.findUnique({ + where: { id }, + include: { + creator: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + + if (!pack) { + throw new NotFoundException('Question pack not found'); + } + + return pack; + } + + async create(createPackDto: CreatePackDto, createdBy: string) { + const { questions, ...data } = createPackDto; + + return this.prisma.questionPack.create({ + data: { + ...data, + createdBy, + questions: questions as any, + questionCount: questions.length, + }, + include: { + creator: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + } + + async update(id: string, updatePackDto: UpdatePackDto) { + const pack = await this.prisma.questionPack.findUnique({ + where: { id }, + }); + + if (!pack) { + throw new NotFoundException('Question pack not found'); + } + + const { questions, ...data } = updatePackDto; + const updateData: any = { ...data }; + + if (questions) { + updateData.questions = questions; + updateData.questionCount = questions.length; + } + + return this.prisma.questionPack.update({ + where: { id }, + data: updateData, + include: { + creator: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + } + + async remove(id: string) { + const pack = await this.prisma.questionPack.findUnique({ + where: { id }, + }); + + if (!pack) { + throw new NotFoundException('Question pack not found'); + } + + await this.prisma.questionPack.delete({ + where: { id }, + }); + + return { message: 'Question pack deleted successfully' }; + } +} diff --git a/backend/src/admin/packs/dto/create-pack.dto.ts b/backend/src/admin/packs/dto/create-pack.dto.ts new file mode 100644 index 0000000..f62f0d7 --- /dev/null +++ b/backend/src/admin/packs/dto/create-pack.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsBoolean, IsArray, IsOptional } from 'class-validator'; + +export class CreatePackDto { + @IsString() + name: string; + + @IsString() + description: string; + + @IsString() + category: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsArray() + questions: Array<{ question: string; answer: string }>; +} diff --git a/backend/src/admin/packs/dto/pack-filters.dto.ts b/backend/src/admin/packs/dto/pack-filters.dto.ts new file mode 100644 index 0000000..6220b1c --- /dev/null +++ b/backend/src/admin/packs/dto/pack-filters.dto.ts @@ -0,0 +1,29 @@ +import { IsOptional, IsBoolean, IsString, IsInt, Min } from 'class-validator'; +import { Type, Transform } from 'class-transformer'; + +export class PackFiltersDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 10; +} diff --git a/backend/src/admin/packs/dto/update-pack.dto.ts b/backend/src/admin/packs/dto/update-pack.dto.ts new file mode 100644 index 0000000..d07f09f --- /dev/null +++ b/backend/src/admin/packs/dto/update-pack.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsBoolean, IsArray, IsOptional } from 'class-validator'; + +export class UpdatePackDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsArray() + questions?: Array<{ question: string; answer: string }>; +} diff --git a/backend/src/admin/rooms/admin-rooms.controller.ts b/backend/src/admin/rooms/admin-rooms.controller.ts new file mode 100644 index 0000000..de2dad9 --- /dev/null +++ b/backend/src/admin/rooms/admin-rooms.controller.ts @@ -0,0 +1,33 @@ +import { + Controller, + Get, + Delete, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { AdminRoomsService } from './admin-rooms.service'; +import { RoomFiltersDto } from './dto/room-filters.dto'; +import { AdminAuthGuard } from '../guards/admin-auth.guard'; +import { AdminGuard } from '../guards/admin.guard'; + +@Controller('api/admin/rooms') +@UseGuards(AdminAuthGuard, AdminGuard) +export class AdminRoomsController { + constructor(private readonly adminRoomsService: AdminRoomsService) {} + + @Get() + findAll(@Query() filters: RoomFiltersDto) { + return this.adminRoomsService.findAll(filters); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.adminRoomsService.findOne(id); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.adminRoomsService.remove(id); + } +} diff --git a/backend/src/admin/rooms/admin-rooms.service.ts b/backend/src/admin/rooms/admin-rooms.service.ts new file mode 100644 index 0000000..9ec2973 --- /dev/null +++ b/backend/src/admin/rooms/admin-rooms.service.ts @@ -0,0 +1,131 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { RoomFiltersDto } from './dto/room-filters.dto'; + +@Injectable() +export class AdminRoomsService { + constructor(private prisma: PrismaService) {} + + async findAll(filters: RoomFiltersDto) { + const { status, dateFrom, dateTo, page = 1, limit = 10 } = filters; + const skip = (page - 1) * limit; + + const where: any = {}; + + if (status) { + where.status = status; + } + + if (dateFrom || dateTo) { + where.createdAt = {}; + if (dateFrom) { + where.createdAt.gte = new Date(dateFrom); + } + if (dateTo) { + where.createdAt.lte = new Date(dateTo); + } + } + + const [rooms, total] = await Promise.all([ + this.prisma.room.findMany({ + where, + skip, + take: limit, + select: { + id: true, + code: true, + status: true, + createdAt: true, + startedAt: true, + finishedAt: true, + maxPlayers: true, + host: { + select: { + id: true, + name: true, + email: true, + }, + }, + questionPack: { + select: { + id: true, + name: true, + }, + }, + _count: { + select: { + participants: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.room.count({ where }), + ]); + + return { + rooms, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(id: string) { + const room = await this.prisma.room.findUnique({ + where: { id }, + include: { + host: { + select: { + id: true, + name: true, + email: true, + }, + }, + questionPack: { + select: { + id: true, + name: true, + description: true, + questionCount: true, + }, + }, + participants: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { score: 'desc' }, + }, + }, + }); + + if (!room) { + throw new NotFoundException('Room not found'); + } + + return room; + } + + async remove(id: string) { + const room = await this.prisma.room.findUnique({ + where: { id }, + }); + + if (!room) { + throw new NotFoundException('Room not found'); + } + + await this.prisma.room.delete({ + where: { id }, + }); + + return { message: 'Room deleted successfully' }; + } +} diff --git a/backend/src/admin/rooms/dto/room-filters.dto.ts b/backend/src/admin/rooms/dto/room-filters.dto.ts new file mode 100644 index 0000000..f2d9b5c --- /dev/null +++ b/backend/src/admin/rooms/dto/room-filters.dto.ts @@ -0,0 +1,28 @@ +import { IsOptional, IsEnum, IsInt, Min, IsDateString } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class RoomFiltersDto { + @IsOptional() + @IsEnum(['WAITING', 'PLAYING', 'FINISHED']) + status?: 'WAITING' | 'PLAYING' | 'FINISHED'; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 10; +} diff --git a/backend/src/admin/users/admin-users.controller.ts b/backend/src/admin/users/admin-users.controller.ts new file mode 100644 index 0000000..5fcbf74 --- /dev/null +++ b/backend/src/admin/users/admin-users.controller.ts @@ -0,0 +1,41 @@ +import { + Controller, + Get, + Patch, + Delete, + Param, + Query, + Body, + UseGuards, +} from '@nestjs/common'; +import { AdminUsersService } from './admin-users.service'; +import { UserFiltersDto } from './dto/user-filters.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { AdminAuthGuard } from '../guards/admin-auth.guard'; +import { AdminGuard } from '../guards/admin.guard'; + +@Controller('api/admin/users') +@UseGuards(AdminAuthGuard, AdminGuard) +export class AdminUsersController { + constructor(private readonly adminUsersService: AdminUsersService) {} + + @Get() + findAll(@Query() filters: UserFiltersDto) { + return this.adminUsersService.findAll(filters); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.adminUsersService.findOne(id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { + return this.adminUsersService.update(id, updateUserDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.adminUsersService.remove(id); + } +} diff --git a/backend/src/admin/users/admin-users.service.ts b/backend/src/admin/users/admin-users.service.ts new file mode 100644 index 0000000..b122bb6 --- /dev/null +++ b/backend/src/admin/users/admin-users.service.ts @@ -0,0 +1,128 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { UserFiltersDto } from './dto/user-filters.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; + +@Injectable() +export class AdminUsersService { + constructor(private prisma: PrismaService) {} + + async findAll(filters: UserFiltersDto) { + const { search, role, page = 1, limit = 10 } = filters; + const skip = (page - 1) * limit; + + const where: any = {}; + + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + ]; + } + + if (role) { + where.role = role; + } + + const [users, total] = await Promise.all([ + this.prisma.user.findMany({ + where, + skip, + take: limit, + select: { + id: true, + email: true, + name: true, + role: true, + telegramId: true, + createdAt: true, + gamesPlayed: true, + gamesWon: true, + totalPoints: true, + }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.user.count({ where }), + ]); + + return { + users, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(id: string) { + const user = await this.prisma.user.findUnique({ + where: { id }, + select: { + id: true, + email: true, + name: true, + role: true, + telegramId: true, + createdAt: true, + gamesPlayed: true, + gamesWon: true, + totalPoints: true, + _count: { + select: { + hostedRooms: true, + participants: true, + questionPacks: true, + }, + }, + }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return user; + } + + async update(id: string, updateUserDto: UpdateUserDto) { + const user = await this.prisma.user.findUnique({ + where: { id }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return this.prisma.user.update({ + where: { id }, + data: updateUserDto, + select: { + id: true, + email: true, + name: true, + role: true, + telegramId: true, + createdAt: true, + gamesPlayed: true, + gamesWon: true, + totalPoints: true, + }, + }); + } + + async remove(id: string) { + const user = await this.prisma.user.findUnique({ + where: { id }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + await this.prisma.user.delete({ + where: { id }, + }); + + return { message: 'User deleted successfully' }; + } +} diff --git a/backend/src/admin/users/dto/update-user.dto.ts b/backend/src/admin/users/dto/update-user.dto.ts new file mode 100644 index 0000000..be92784 --- /dev/null +++ b/backend/src/admin/users/dto/update-user.dto.ts @@ -0,0 +1,15 @@ +import { IsOptional, IsString, IsEnum } from 'class-validator'; + +export class UpdateUserDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsEnum(['USER', 'ADMIN']) + role?: 'USER' | 'ADMIN'; +} diff --git a/backend/src/admin/users/dto/user-filters.dto.ts b/backend/src/admin/users/dto/user-filters.dto.ts new file mode 100644 index 0000000..f168248 --- /dev/null +++ b/backend/src/admin/users/dto/user-filters.dto.ts @@ -0,0 +1,24 @@ +import { IsOptional, IsString, IsEnum, IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class UserFiltersDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(['USER', 'ADMIN']) + role?: 'USER' | 'ADMIN'; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 10; +} diff --git a/backend/src/admin/utils/admin-checker.util.ts b/backend/src/admin/utils/admin-checker.util.ts new file mode 100644 index 0000000..4a5029f --- /dev/null +++ b/backend/src/admin/utils/admin-checker.util.ts @@ -0,0 +1,82 @@ +import { ConfigService } from '@nestjs/config'; + +/** + * Utility class for checking admin privileges based on Telegram ID + */ +export class AdminChecker { + private static adminIds: Set | null = null; + + /** + * Initialize the admin IDs list from environment variables + */ + static initialize(configService: ConfigService): void { + const adminIdsEnv = configService.get('ADMIN_IDS', ''); + + if (adminIdsEnv) { + // Split by comma and trim whitespace + const ids = adminIdsEnv + .split(',') + .map(id => id.trim()) + .filter(id => id.length > 0); + + this.adminIds = new Set(ids); + } else { + this.adminIds = new Set(); + } + } + + /** + * Check if a Telegram ID is in the admin list + */ + static isAdmin(telegramId: string | null | undefined): boolean { + if (!telegramId) { + return false; + } + + if (this.adminIds === null) { + throw new Error('AdminChecker not initialized. Call initialize() first.'); + } + + return this.adminIds.has(telegramId); + } + + /** + * Get all admin IDs + */ + static getAdminIds(): string[] { + if (this.adminIds === null) { + throw new Error('AdminChecker not initialized. Call initialize() first.'); + } + + return Array.from(this.adminIds); + } + + /** + * Add an admin ID dynamically (useful for testing) + */ + static addAdmin(telegramId: string): void { + if (this.adminIds === null) { + this.adminIds = new Set(); + } + + this.adminIds.add(telegramId); + } + + /** + * Remove an admin ID dynamically (useful for testing) + */ + static removeAdmin(telegramId: string): void { + if (this.adminIds === null) { + return; + } + + this.adminIds.delete(telegramId); + } + + /** + * Clear all admin IDs + */ + static clear(): void { + this.adminIds = new Set(); + } +} diff --git a/src/components/ThemeSwitcher.css b/src/components/ThemeSwitcher.css index 4042792..c3a703e 100644 --- a/src/components/ThemeSwitcher.css +++ b/src/components/ThemeSwitcher.css @@ -57,6 +57,13 @@ animation: slideIn 0.3s ease; } +/* Позиционирование меню справа для главной страницы */ +.home-theme-switcher-wrapper .theme-switcher-menu { + left: auto; + right: 0; + max-width: min(400px, calc(100vw - 2rem)); +} + @keyframes fadeIn { from { opacity: 0; diff --git a/src/hooks/useRoom.js b/src/hooks/useRoom.js index e868542..a289f5c 100644 --- a/src/hooks/useRoom.js +++ b/src/hooks/useRoom.js @@ -136,6 +136,22 @@ export const useRoom = (roomCode) => { } }, [room]); + const updateQuestionPack = useCallback( + async (questionPackId) => { + if (room) { + try { + const response = await roomsApi.updateQuestionPack(room.id, questionPackId); + setRoom(response.data); + return response.data; + } catch (err) { + setError(err.message); + throw err; + } + } + }, + [room], + ); + return { room, participants, @@ -148,5 +164,6 @@ export const useRoom = (roomCode) => { updateScore, nextQuestion, endGame, + updateQuestionPack, }; }; diff --git a/src/pages/CreateRoom.jsx b/src/pages/CreateRoom.jsx index 1c00370..a6b0ea3 100644 --- a/src/pages/CreateRoom.jsx +++ b/src/pages/CreateRoom.jsx @@ -24,9 +24,6 @@ const CreateRoom = () => { try { const response = await questionsApi.getPacks(user?.id); setQuestionPacks(response.data); - if (response.data.length > 0) { - setSelectedPackId(response.data[0].id); - } } catch (error) { console.error('Error fetching question packs:', error); } finally { @@ -38,13 +35,17 @@ const CreateRoom = () => { }, [user]); const handleCreateRoom = async () => { - if (!user || !selectedPackId) { - alert('Выберите пак вопросов'); + if (!user) { + alert('Войдите в систему для создания комнаты'); return; } try { - const room = await createRoom(user.id, selectedPackId, settings); + const room = await createRoom( + user.id, + selectedPackId || undefined, + settings, + ); navigate(`/room/${room.code}`); } catch (error) { console.error('Error creating room:', error); @@ -62,11 +63,12 @@ const CreateRoom = () => {

Создать комнату

- + setSelectedPackId(e.target.value)} + disabled={loadingPacks || updatingPack} + > + + {questionPacks.map((pack) => ( + + ))} + + +
+ )} + +