backend admin and room

This commit is contained in:
Dmitry 2026-01-06 23:27:50 +03:00
parent 7ad18d53f3
commit c69a77b997
32 changed files with 1980 additions and 60 deletions

View file

@ -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<string>('ADMIN_JWT_SECRET') ||
configService.get<string>('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 {}

View file

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

View file

@ -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<string, number>();
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 };
}
}

View file

@ -0,0 +1,11 @@
import { IsOptional, IsDateString } from 'class-validator';
export class DateRangeDto {
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
}

View file

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

View file

@ -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<void> {
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<void> {
// 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<string>('ADMIN_JWT_SECRET') ||
this.configService.get<string>('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<string>('ADMIN_JWT_SECRET') ||
this.configService.get<string>('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<any> {
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();
}
}

View file

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken: string;
}

View file

@ -0,0 +1,3 @@
export class RequestCodeDto {
// Empty - no request body needed for code generation
}

View file

@ -0,0 +1,7 @@
import { IsString, Length } from 'class-validator';
export class VerifyCodeDto {
@IsString()
@Length(6, 6)
code: string;
}

View file

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

View file

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

View file

@ -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<boolean> {
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<string>('ADMIN_JWT_SECRET') ||
this.configService.get<string>('JWT_SECRET');
const payload = await this.jwtService.verifyAsync(token, { secret });
request.user = payload;
return true;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string> | null = null;
/**
* Initialize the admin IDs list from environment variables
*/
static initialize(configService: ConfigService): void {
const adminIdsEnv = configService.get<string>('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();
}
}

View file

@ -57,6 +57,13 @@
animation: slideIn 0.3s ease; 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 { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;

View file

@ -136,6 +136,22 @@ export const useRoom = (roomCode) => {
} }
}, [room]); }, [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 { return {
room, room,
participants, participants,
@ -148,5 +164,6 @@ export const useRoom = (roomCode) => {
updateScore, updateScore,
nextQuestion, nextQuestion,
endGame, endGame,
updateQuestionPack,
}; };
}; };

View file

@ -24,9 +24,6 @@ const CreateRoom = () => {
try { try {
const response = await questionsApi.getPacks(user?.id); const response = await questionsApi.getPacks(user?.id);
setQuestionPacks(response.data); setQuestionPacks(response.data);
if (response.data.length > 0) {
setSelectedPackId(response.data[0].id);
}
} catch (error) { } catch (error) {
console.error('Error fetching question packs:', error); console.error('Error fetching question packs:', error);
} finally { } finally {
@ -38,13 +35,17 @@ const CreateRoom = () => {
}, [user]); }, [user]);
const handleCreateRoom = async () => { const handleCreateRoom = async () => {
if (!user || !selectedPackId) { if (!user) {
alert('Выберите пак вопросов'); alert('Войдите в систему для создания комнаты');
return; return;
} }
try { try {
const room = await createRoom(user.id, selectedPackId, settings); const room = await createRoom(
user.id,
selectedPackId || undefined,
settings,
);
navigate(`/room/${room.code}`); navigate(`/room/${room.code}`);
} catch (error) { } catch (error) {
console.error('Error creating room:', error); console.error('Error creating room:', error);
@ -62,11 +63,12 @@ const CreateRoom = () => {
<h1>Создать комнату</h1> <h1>Создать комнату</h1>
<div className="form-group"> <div className="form-group">
<label>Выберите пак вопросов:</label> <label>Выберите пак вопросов (можно добавить позже):</label>
<select <select
value={selectedPackId} value={selectedPackId}
onChange={(e) => setSelectedPackId(e.target.value)} onChange={(e) => setSelectedPackId(e.target.value)}
> >
<option value="">Без пака вопросов</option>
{questionPacks.map((pack) => ( {questionPacks.map((pack) => (
<option key={pack.id} value={pack.id}> <option key={pack.id} value={pack.id}>
{pack.name} ({pack.questionCount} вопросов) {pack.name} ({pack.questionCount} вопросов)
@ -132,7 +134,7 @@ const CreateRoom = () => {
<div className="button-group"> <div className="button-group">
<button <button
onClick={handleCreateRoom} onClick={handleCreateRoom}
disabled={roomLoading || !selectedPackId} disabled={roomLoading}
className="primary" className="primary"
> >
{roomLoading ? 'Создание...' : 'Создать комнату'} {roomLoading ? 'Создание...' : 'Создать комнату'}

View file

@ -46,61 +46,147 @@ const JoinRoom = () => {
setScanError(''); setScanError('');
setIsScanning(true); setIsScanning(true);
// Небольшая задержка, чтобы DOM успел обновиться
await new Promise(resolve => setTimeout(resolve, 100));
// Проверяем, что элемент существует
const element = document.getElementById('qr-reader');
if (!element) {
throw new Error('Элемент сканера не найден');
}
const html5QrCode = new Html5Qrcode('qr-reader'); const html5QrCode = new Html5Qrcode('qr-reader');
html5QrCodeRef.current = html5QrCode; html5QrCodeRef.current = html5QrCode;
await html5QrCode.start( // Пытаемся получить список камер для лучшей совместимости
{ facingMode: 'environment' }, let cameraId = null;
{ try {
fps: 10, const devices = await Html5Qrcode.getCameras();
qrbox: { width: 250, height: 250 }, if (devices && devices.length > 0) {
}, // Предпочитаем заднюю камеру (environment), если доступна
(decodedText) => { const backCamera = devices.find(device =>
try { device.label.toLowerCase().includes('back') ||
let code = null; device.label.toLowerCase().includes('rear') ||
device.label.toLowerCase().includes('environment')
// Проверяем, что это ссылка на join-room с кодом );
try { cameraId = backCamera ? backCamera.id : devices[0].id;
// Пытаемся создать URL (может быть абсолютным или относительным)
let url;
if (decodedText.startsWith('http://') || decodedText.startsWith('https://')) {
url = new URL(decodedText);
} else if (decodedText.startsWith('/')) {
// Относительный URL - создаем на основе текущего origin
url = new URL(decodedText, window.location.origin);
} else {
// Не URL, возможно просто код
throw new Error('Not a URL');
}
if (url.pathname === '/join-room' || url.pathname.endsWith('/join-room')) {
code = url.searchParams.get('code');
}
} catch (urlError) {
// Если это не валидный URL, проверяем, не является ли это просто кодом
}
// Если нашли код в URL или это просто код комнаты (6 символов)
if (code && code.length === 6) {
stopScanning();
navigate(`/room/${code.toUpperCase()}`, { replace: true });
} else if (decodedText.length === 6 && /^[A-Z0-9]{6}$/i.test(decodedText)) {
stopScanning();
navigate(`/room/${decodedText.toUpperCase()}`, { replace: true });
} else {
setScanError('Неверный QR-код. Отсканируйте QR-код комнаты.');
}
} catch (err) {
setScanError('Ошибка обработки QR-кода');
}
},
(errorMessage) => {
// Игнорируем ошибки сканирования (они происходят постоянно)
} }
); } catch (err) {
console.log('Не удалось получить список камер, используем facingMode');
}
const config = {
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0,
};
// Пытаемся запустить с конкретной камерой или с facingMode
try {
if (cameraId) {
await html5QrCode.start(
cameraId,
config,
(decodedText) => {
handleQRCodeScanned(decodedText);
},
(errorMessage) => {
// Игнорируем ошибки сканирования (они происходят постоянно)
}
);
} else {
await html5QrCode.start(
{ facingMode: 'environment' },
config,
(decodedText) => {
handleQRCodeScanned(decodedText);
},
(errorMessage) => {
// Игнорируем ошибки сканирования (они происходят постоянно)
}
);
}
} catch (err) {
// Если не удалось с environment, пробуем user (передняя камера)
if (err.message && err.message.includes('environment')) {
try {
await html5QrCode.start(
{ facingMode: 'user' },
config,
(decodedText) => {
handleQRCodeScanned(decodedText);
},
(errorMessage) => {
// Игнорируем ошибки сканирования
}
);
} catch (err2) {
throw err2;
}
} else {
throw err;
}
}
} catch (err) { } catch (err) {
setScanError('Не удалось запустить камеру. Проверьте разрешения.'); console.error('Ошибка запуска сканера:', err);
let errorMessage = 'Не удалось запустить камеру. ';
if (err.name === 'NotAllowedError' || err.message?.includes('permission')) {
errorMessage += 'Разрешите доступ к камере в настройках браузера.';
} else if (err.name === 'NotFoundError' || err.message?.includes('camera')) {
errorMessage += 'Камера не найдена.';
} else if (err.message) {
errorMessage += err.message;
} else {
errorMessage += 'Проверьте разрешения и попробуйте снова.';
}
setScanError(errorMessage);
setIsScanning(false); setIsScanning(false);
if (html5QrCodeRef.current) {
html5QrCodeRef.current = null;
}
}
};
const handleQRCodeScanned = (decodedText) => {
try {
let code = null;
// Проверяем, что это ссылка на join-room с кодом
try {
// Пытаемся создать URL (может быть абсолютным или относительным)
let url;
if (decodedText.startsWith('http://') || decodedText.startsWith('https://')) {
url = new URL(decodedText);
} else if (decodedText.startsWith('/')) {
// Относительный URL - создаем на основе текущего origin
url = new URL(decodedText, window.location.origin);
} else {
// Не URL, возможно просто код
throw new Error('Not a URL');
}
if (url.pathname === '/join-room' || url.pathname.endsWith('/join-room')) {
code = url.searchParams.get('code');
}
} catch (urlError) {
// Если это не валидный URL, проверяем, не является ли это просто кодом
}
// Если нашли код в URL или это просто код комнаты (6 символов)
if (code && code.length === 6) {
stopScanning();
navigate(`/room/${code.toUpperCase()}`, { replace: true });
} else if (decodedText.length === 6 && /^[A-Z0-9]{6}$/i.test(decodedText)) {
stopScanning();
navigate(`/room/${decodedText.toUpperCase()}`, { replace: true });
} else {
setScanError('Неверный QR-код. Отсканируйте QR-код комнаты.');
}
} catch (err) {
console.error('Ошибка обработки QR-кода:', err);
setScanError('Ошибка обработки QR-кода');
} }
}; };
@ -174,13 +260,16 @@ const JoinRoom = () => {
) : ( ) : (
<div className="qr-scanner-container"> <div className="qr-scanner-container">
<div className="qr-scanner-wrapper"> <div className="qr-scanner-wrapper">
<div id="qr-reader" className="qr-reader"></div> <div id="qr-reader" className="qr-reader" style={{ minHeight: '300px' }}></div>
</div> </div>
{scanError && ( {scanError && (
<div className="qr-scan-error">{scanError}</div> <div className="qr-scan-error">{scanError}</div>
)} )}
<div className="qr-scanner-instructions"> <div className="qr-scanner-instructions">
<p>Наведите камеру на QR-код комнаты</p> <p>Наведите камеру на QR-код комнаты</p>
<p style={{ fontSize: '0.85rem', color: 'rgba(255, 255, 255, 0.6)', marginTop: '10px' }}>
Если камера не запускается, проверьте разрешения в настройках браузера
</p>
</div> </div>
<div className="button-group"> <div className="button-group">
<button onClick={stopScanning} className="secondary"> <button onClick={stopScanning} className="secondary">

View file

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useRoom } from '../hooks/useRoom'; import { useRoom } from '../hooks/useRoom';
import { questionsApi } from '../services/api';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import QRModal from '../components/QRModal'; import QRModal from '../components/QRModal';
@ -9,10 +10,22 @@ const RoomPage = () => {
const { roomCode } = useParams(); const { roomCode } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { room, participants, loading, error, joinRoom, startGame } = useRoom(roomCode); const {
room,
participants,
loading,
error,
joinRoom,
startGame,
updateQuestionPack,
} = useRoom(roomCode);
const [qrCode, setQrCode] = useState(''); const [qrCode, setQrCode] = useState('');
const [joined, setJoined] = useState(false); const [joined, setJoined] = useState(false);
const [isQRModalOpen, setIsQRModalOpen] = useState(false); const [isQRModalOpen, setIsQRModalOpen] = useState(false);
const [questionPacks, setQuestionPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState('');
const [loadingPacks, setLoadingPacks] = useState(false);
const [updatingPack, setUpdatingPack] = useState(false);
useEffect(() => { useEffect(() => {
const generateQR = async () => { const generateQR = async () => {
@ -58,11 +71,61 @@ const RoomPage = () => {
handleJoin(); handleJoin();
}, [room, user, participants, joined, joinRoom]); }, [room, user, participants, joined, joinRoom]);
useEffect(() => {
const fetchPacks = async () => {
if (user) {
try {
setLoadingPacks(true);
const response = await questionsApi.getPacks(user.id);
setQuestionPacks(response.data);
} catch (error) {
console.error('Error fetching question packs:', error);
} finally {
setLoadingPacks(false);
}
}
};
if (room && user && room.hostId === user.id) {
fetchPacks();
}
}, [room, user]);
useEffect(() => {
if (room && room.questionPackId) {
setSelectedPackId(room.questionPackId);
} else {
setSelectedPackId('');
}
}, [room]);
const handleStartGame = () => { const handleStartGame = () => {
if (!room.questionPackId) {
alert('Выберите пак вопросов перед началом игры');
return;
}
startGame(); startGame();
navigate(`/game/${roomCode}`); navigate(`/game/${roomCode}`);
}; };
const handleUpdateQuestionPack = async () => {
if (!selectedPackId) {
alert('Выберите пак вопросов');
return;
}
try {
setUpdatingPack(true);
await updateQuestionPack(selectedPackId);
alert('Пак вопросов успешно добавлен');
} catch (error) {
console.error('Error updating question pack:', error);
alert('Ошибка при обновлении пака вопросов');
} finally {
setUpdatingPack(false);
}
};
if (loading) { if (loading) {
return <div className="loading">Загрузка комнаты...</div>; return <div className="loading">Загрузка комнаты...</div>;
} }
@ -110,6 +173,58 @@ const RoomPage = () => {
</ul> </ul>
</div> </div>
<div className="question-pack-section">
<h3>Пак вопросов:</h3>
{room.questionPack ? (
<div className="pack-info">
<p>
<strong>{room.questionPack.name}</strong> (
{room.questionPack.questionCount || 0} вопросов)
</p>
{isHost && room.status === 'WAITING' && (
<p className="pack-hint">
Можете изменить пак вопросов перед началом игры
</p>
)}
</div>
) : (
<div className="pack-info">
<p className="pack-warning">
Пак вопросов не выбран. Выберите пак для начала игры.
</p>
</div>
)}
{isHost && room.status === 'WAITING' && (
<div className="pack-selector">
<select
value={selectedPackId}
onChange={(e) => setSelectedPackId(e.target.value)}
disabled={loadingPacks || updatingPack}
>
<option value="">Выберите пак вопросов</option>
{questionPacks.map((pack) => (
<option key={pack.id} value={pack.id}>
{pack.name} ({pack.questionCount} вопросов)
</option>
))}
</select>
<button
onClick={handleUpdateQuestionPack}
disabled={
!selectedPackId ||
selectedPackId === room.questionPackId ||
updatingPack ||
loadingPacks
}
className="secondary"
>
{updatingPack ? 'Сохранение...' : 'Сохранить пак'}
</button>
</div>
)}
</div>
<div className="button-group"> <div className="button-group">
<button <button
onClick={() => setIsQRModalOpen(true)} onClick={() => setIsQRModalOpen(true)}
@ -120,7 +235,7 @@ const RoomPage = () => {
{isHost && room.status === 'WAITING' && ( {isHost && room.status === 'WAITING' && (
<button <button
onClick={handleStartGame} onClick={handleStartGame}
disabled={participants.length < 2} disabled={participants.length < 2 || !room.questionPackId}
className="primary" className="primary"
> >
Начать игру Начать игру

View file

@ -21,6 +21,8 @@ export const roomsApi = {
getByCode: (code) => api.get(`/rooms/${code}`), getByCode: (code) => api.get(`/rooms/${code}`),
join: (roomId, userId, name, role) => join: (roomId, userId, name, role) =>
api.post(`/rooms/${roomId}/join`, { userId, name, role }), api.post(`/rooms/${roomId}/join`, { userId, name, role }),
updateQuestionPack: (roomId, questionPackId) =>
api.patch(`/rooms/${roomId}/question-pack`, { questionPackId }),
}; };
// Questions endpoints // Questions endpoints