backend admin and room
This commit is contained in:
parent
7ad18d53f3
commit
c69a77b997
32 changed files with 1980 additions and 60 deletions
70
backend/src/admin/admin.module.ts
Normal file
70
backend/src/admin/admin.module.ts
Normal 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 {}
|
||||
38
backend/src/admin/analytics/admin-analytics.controller.ts
Normal file
38
backend/src/admin/analytics/admin-analytics.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
202
backend/src/admin/analytics/admin-analytics.service.ts
Normal file
202
backend/src/admin/analytics/admin-analytics.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
11
backend/src/admin/analytics/dto/date-range.dto.ts
Normal file
11
backend/src/admin/analytics/dto/date-range.dto.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { IsOptional, IsDateString } from 'class-validator';
|
||||
|
||||
export class DateRangeDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
}
|
||||
61
backend/src/admin/auth/admin-auth.controller.ts
Normal file
61
backend/src/admin/auth/admin-auth.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
328
backend/src/admin/auth/admin-auth.service.ts
Normal file
328
backend/src/admin/auth/admin-auth.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
6
backend/src/admin/auth/dto/refresh-token.dto.ts
Normal file
6
backend/src/admin/auth/dto/refresh-token.dto.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
3
backend/src/admin/auth/dto/request-code.dto.ts
Normal file
3
backend/src/admin/auth/dto/request-code.dto.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class RequestCodeDto {
|
||||
// Empty - no request body needed for code generation
|
||||
}
|
||||
7
backend/src/admin/auth/dto/verify-code.dto.ts
Normal file
7
backend/src/admin/auth/dto/verify-code.dto.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { IsString, Length } from 'class-validator';
|
||||
|
||||
export class VerifyCodeDto {
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
code: string;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
79
backend/src/admin/game-history/admin-game-history.service.ts
Normal file
79
backend/src/admin/game-history/admin-game-history.service.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
38
backend/src/admin/guards/admin-auth.guard.ts
Normal file
38
backend/src/admin/guards/admin-auth.guard.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
49
backend/src/admin/guards/admin.guard.ts
Normal file
49
backend/src/admin/guards/admin.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
49
backend/src/admin/packs/admin-packs.controller.ts
Normal file
49
backend/src/admin/packs/admin-packs.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
160
backend/src/admin/packs/admin-packs.service.ts
Normal file
160
backend/src/admin/packs/admin-packs.service.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
19
backend/src/admin/packs/dto/create-pack.dto.ts
Normal file
19
backend/src/admin/packs/dto/create-pack.dto.ts
Normal 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 }>;
|
||||
}
|
||||
29
backend/src/admin/packs/dto/pack-filters.dto.ts
Normal file
29
backend/src/admin/packs/dto/pack-filters.dto.ts
Normal 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;
|
||||
}
|
||||
23
backend/src/admin/packs/dto/update-pack.dto.ts
Normal file
23
backend/src/admin/packs/dto/update-pack.dto.ts
Normal 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 }>;
|
||||
}
|
||||
33
backend/src/admin/rooms/admin-rooms.controller.ts
Normal file
33
backend/src/admin/rooms/admin-rooms.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
131
backend/src/admin/rooms/admin-rooms.service.ts
Normal file
131
backend/src/admin/rooms/admin-rooms.service.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
28
backend/src/admin/rooms/dto/room-filters.dto.ts
Normal file
28
backend/src/admin/rooms/dto/room-filters.dto.ts
Normal 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;
|
||||
}
|
||||
41
backend/src/admin/users/admin-users.controller.ts
Normal file
41
backend/src/admin/users/admin-users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
128
backend/src/admin/users/admin-users.service.ts
Normal file
128
backend/src/admin/users/admin-users.service.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
15
backend/src/admin/users/dto/update-user.dto.ts
Normal file
15
backend/src/admin/users/dto/update-user.dto.ts
Normal 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';
|
||||
}
|
||||
24
backend/src/admin/users/dto/user-filters.dto.ts
Normal file
24
backend/src/admin/users/dto/user-filters.dto.ts
Normal 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;
|
||||
}
|
||||
82
backend/src/admin/utils/admin-checker.util.ts
Normal file
82
backend/src/admin/utils/admin-checker.util.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<h1>Создать комнату</h1>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Выберите пак вопросов:</label>
|
||||
<label>Выберите пак вопросов (можно добавить позже):</label>
|
||||
<select
|
||||
value={selectedPackId}
|
||||
onChange={(e) => setSelectedPackId(e.target.value)}
|
||||
>
|
||||
<option value="">Без пака вопросов</option>
|
||||
{questionPacks.map((pack) => (
|
||||
<option key={pack.id} value={pack.id}>
|
||||
{pack.name} ({pack.questionCount} вопросов)
|
||||
|
|
@ -132,7 +134,7 @@ const CreateRoom = () => {
|
|||
<div className="button-group">
|
||||
<button
|
||||
onClick={handleCreateRoom}
|
||||
disabled={roomLoading || !selectedPackId}
|
||||
disabled={roomLoading}
|
||||
className="primary"
|
||||
>
|
||||
{roomLoading ? 'Создание...' : 'Создать комнату'}
|
||||
|
|
|
|||
|
|
@ -46,16 +46,110 @@ const JoinRoom = () => {
|
|||
setScanError('');
|
||||
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');
|
||||
html5QrCodeRef.current = html5QrCode;
|
||||
|
||||
await html5QrCode.start(
|
||||
{ facingMode: 'environment' },
|
||||
{
|
||||
// Пытаемся получить список камер для лучшей совместимости
|
||||
let cameraId = null;
|
||||
try {
|
||||
const devices = await Html5Qrcode.getCameras();
|
||||
if (devices && devices.length > 0) {
|
||||
// Предпочитаем заднюю камеру (environment), если доступна
|
||||
const backCamera = devices.find(device =>
|
||||
device.label.toLowerCase().includes('back') ||
|
||||
device.label.toLowerCase().includes('rear') ||
|
||||
device.label.toLowerCase().includes('environment')
|
||||
);
|
||||
cameraId = backCamera ? backCamera.id : devices[0].id;
|
||||
}
|
||||
} 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) {
|
||||
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);
|
||||
if (html5QrCodeRef.current) {
|
||||
html5QrCodeRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleQRCodeScanned = (decodedText) => {
|
||||
try {
|
||||
let code = null;
|
||||
|
||||
|
|
@ -91,17 +185,9 @@ const JoinRoom = () => {
|
|||
setScanError('Неверный QR-код. Отсканируйте QR-код комнаты.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка обработки QR-кода:', err);
|
||||
setScanError('Ошибка обработки QR-кода');
|
||||
}
|
||||
},
|
||||
(errorMessage) => {
|
||||
// Игнорируем ошибки сканирования (они происходят постоянно)
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
setScanError('Не удалось запустить камеру. Проверьте разрешения.');
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopScanning = () => {
|
||||
|
|
@ -174,13 +260,16 @@ const JoinRoom = () => {
|
|||
) : (
|
||||
<div className="qr-scanner-container">
|
||||
<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>
|
||||
{scanError && (
|
||||
<div className="qr-scan-error">{scanError}</div>
|
||||
)}
|
||||
<div className="qr-scanner-instructions">
|
||||
<p>Наведите камеру на QR-код комнаты</p>
|
||||
<p style={{ fontSize: '0.85rem', color: 'rgba(255, 255, 255, 0.6)', marginTop: '10px' }}>
|
||||
Если камера не запускается, проверьте разрешения в настройках браузера
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-group">
|
||||
<button onClick={stopScanning} className="secondary">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useRoom } from '../hooks/useRoom';
|
||||
import { questionsApi } from '../services/api';
|
||||
import QRCode from 'qrcode';
|
||||
import QRModal from '../components/QRModal';
|
||||
|
||||
|
|
@ -9,10 +10,22 @@ const RoomPage = () => {
|
|||
const { roomCode } = useParams();
|
||||
const navigate = useNavigate();
|
||||
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 [joined, setJoined] = 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(() => {
|
||||
const generateQR = async () => {
|
||||
|
|
@ -58,11 +71,61 @@ const RoomPage = () => {
|
|||
handleJoin();
|
||||
}, [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 = () => {
|
||||
if (!room.questionPackId) {
|
||||
alert('Выберите пак вопросов перед началом игры');
|
||||
return;
|
||||
}
|
||||
startGame();
|
||||
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) {
|
||||
return <div className="loading">Загрузка комнаты...</div>;
|
||||
}
|
||||
|
|
@ -110,6 +173,58 @@ const RoomPage = () => {
|
|||
</ul>
|
||||
</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">
|
||||
<button
|
||||
onClick={() => setIsQRModalOpen(true)}
|
||||
|
|
@ -120,7 +235,7 @@ const RoomPage = () => {
|
|||
{isHost && room.status === 'WAITING' && (
|
||||
<button
|
||||
onClick={handleStartGame}
|
||||
disabled={participants.length < 2}
|
||||
disabled={participants.length < 2 || !room.questionPackId}
|
||||
className="primary"
|
||||
>
|
||||
Начать игру
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export const roomsApi = {
|
|||
getByCode: (code) => api.get(`/rooms/${code}`),
|
||||
join: (roomId, userId, name, role) =>
|
||||
api.post(`/rooms/${roomId}/join`, { userId, name, role }),
|
||||
updateQuestionPack: (roomId, questionPackId) =>
|
||||
api.patch(`/rooms/${roomId}/question-pack`, { questionPackId }),
|
||||
};
|
||||
|
||||
// Questions endpoints
|
||||
|
|
|
|||
Loading…
Reference in a new issue