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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 ? 'Создание...' : 'Создать комнату'}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
Начать игру
|
Начать игру
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue