Add Keep-Me-Signed-In, movie release dates, and lint cleanup
Features:
- "Keep me signed in" — login/register accept a rememberMe flag and the
backend signs JWTs with JWT_LONG_EXPIRATION (30d) when set, otherwise
JWT_EXPIRATION (1d). DTOs, controller, and AuthService updated.
- Movie release dates — DailyChallenge, GameSession, and VersusMatch get
nullable movieAReleaseDate / movieBReleaseDate columns (forward-only
migration). Daily-challenges, games, versus-match, and versus-async
services now persist and return release_date from TMDB. Versus waiting
lobby payloads strip the new fields alongside the titles to avoid
leaking the puzzle to non-joined players.
Lint cleanup (132 → 0 errors):
- Shared utilities: src/common/utils/error.util.ts (getErrorMessage) and
src/auth/types/jwt-payload.ts (JwtPayload).
- Replaced catch(error: any){error.message} with getErrorMessage(error)
across services and gateways.
- jwtService.verify<JwtPayload>(token) in versus / game-night / chat /
notifications gateways.
- Typed Prisma JSON columns where they're read (game-night, leaderboards,
admin score blobs).
- Removed redundant async on sync handlers, wrapped setTimeout/setInterval
Promise callbacks with void IIFEs to satisfy no-misused-promises.
- eslint.config.mjs: allow `_`-prefixed unused vars (industry standard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ RABBITMQ_URL=amqp://rabbit:rabbit_secret@localhost:5672
|
||||
# JWT
|
||||
JWT_SECRET=change-me-to-a-random-secret
|
||||
JWT_EXPIRATION=1d
|
||||
JWT_LONG_EXPIRATION=30d
|
||||
|
||||
# TMDB
|
||||
TMDB_API_KEY=your-tmdb-api-key-here
|
||||
|
||||
@@ -29,6 +29,15 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add nullable movie release-date columns. Forward-only: existing rows remain NULL.
|
||||
ALTER TABLE "daily_challenges" ADD COLUMN "movie_a_release_date" TEXT;
|
||||
ALTER TABLE "daily_challenges" ADD COLUMN "movie_b_release_date" TEXT;
|
||||
|
||||
ALTER TABLE "game_sessions" ADD COLUMN "movie_a_release_date" TEXT;
|
||||
ALTER TABLE "game_sessions" ADD COLUMN "movie_b_release_date" TEXT;
|
||||
|
||||
ALTER TABLE "versus_matches" ADD COLUMN "movie_a_release_date" TEXT;
|
||||
ALTER TABLE "versus_matches" ADD COLUMN "movie_b_release_date" TEXT;
|
||||
@@ -46,8 +46,10 @@ model GameSession {
|
||||
userId String? @map("user_id")
|
||||
movieAId Int @map("movie_a_id")
|
||||
movieATitle String @map("movie_a_title")
|
||||
movieAReleaseDate String? @map("movie_a_release_date")
|
||||
movieBId Int @map("movie_b_id")
|
||||
movieBTitle String @map("movie_b_title")
|
||||
movieBReleaseDate String? @map("movie_b_release_date")
|
||||
chain Json @default("[]")
|
||||
score Json?
|
||||
hintsUsed Int @default(0) @map("hints_used")
|
||||
@@ -68,8 +70,10 @@ model DailyChallenge {
|
||||
date DateTime @db.Date
|
||||
movieAId Int @map("movie_a_id")
|
||||
movieATitle String @map("movie_a_title")
|
||||
movieAReleaseDate String? @map("movie_a_release_date")
|
||||
movieBId Int @map("movie_b_id")
|
||||
movieBTitle String @map("movie_b_title")
|
||||
movieBReleaseDate String? @map("movie_b_release_date")
|
||||
par Int
|
||||
difficulty String @default("medium")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -135,8 +139,10 @@ model VersusMatch {
|
||||
player2Id String? @map("player2_id")
|
||||
movieAId Int @map("movie_a_id")
|
||||
movieATitle String @map("movie_a_title")
|
||||
movieAReleaseDate String? @map("movie_a_release_date")
|
||||
movieBId Int @map("movie_b_id")
|
||||
movieBTitle String @map("movie_b_title")
|
||||
movieBReleaseDate String? @map("movie_b_release_date")
|
||||
difficulty String @default("medium")
|
||||
status String @default("waiting")
|
||||
lobbyName String? @map("lobby_name")
|
||||
|
||||
@@ -35,7 +35,10 @@ export class AdminController {
|
||||
@Post('promote')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Promote yourself to admin using ADMIN_SECRET' })
|
||||
promoteToAdmin(@Req() req: any, @Body() body: PromoteAdminDto) {
|
||||
promoteToAdmin(
|
||||
@Req() req: { user: { sub: string } },
|
||||
@Body() body: PromoteAdminDto,
|
||||
) {
|
||||
return this.adminService.promoteToAdmin(req.user.sub, body.adminSecret);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DailyChallengesService } from '../daily-challenges/daily-challenges.service';
|
||||
import { AchievementsService } from '../achievements/achievements.service';
|
||||
import { ScoreService } from '../games/score.service';
|
||||
import type { ChainLinkDto } from '../games/dto/chain-link.dto';
|
||||
import type { DailyChallengeModel } from '../../generated/prisma/models';
|
||||
import { getUtcDate } from '../common/utils/date.util';
|
||||
import { calculateCurrentStreak } from '../common/utils/streak.util';
|
||||
|
||||
@@ -26,7 +28,7 @@ export class AdminService {
|
||||
|
||||
async generateAllChallenges(dateStr: string) {
|
||||
const date = new Date(dateStr + 'T00:00:00.000Z');
|
||||
const results: any[] = [];
|
||||
const results: DailyChallengeModel[] = [];
|
||||
for (const difficulty of ['easy', 'medium', 'hard'] as const) {
|
||||
const challenge = await this.dailyChallengesService.generateChallenge(
|
||||
date,
|
||||
@@ -137,7 +139,7 @@ export class AdminService {
|
||||
}
|
||||
|
||||
const newScore = this.scoreService.calculate(
|
||||
game.chain as any[],
|
||||
game.chain as unknown as ChainLinkDto[],
|
||||
game.startedAt,
|
||||
game.completedAt,
|
||||
game.hintsUsed,
|
||||
@@ -146,7 +148,7 @@ export class AdminService {
|
||||
|
||||
await this.prisma.gameSession.update({
|
||||
where: { id: game.id },
|
||||
data: { score: newScore as any },
|
||||
data: { score: newScore as unknown as object },
|
||||
});
|
||||
|
||||
updated++;
|
||||
@@ -251,11 +253,19 @@ export class AdminService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const score = game.score as any;
|
||||
const minActorPopularity = (game.chain as any[])
|
||||
.filter((l: any) => l.type === 'actor')
|
||||
const score = game.score as {
|
||||
totalScore?: number;
|
||||
linkCount?: number;
|
||||
elapsedSeconds?: number;
|
||||
} | null;
|
||||
const chain = game.chain as unknown as Array<{
|
||||
type: string;
|
||||
popularity?: number;
|
||||
}>;
|
||||
const minActorPopularity = chain
|
||||
.filter((l) => l.type === 'actor')
|
||||
.reduce(
|
||||
(min: number, l: any) => Math.min(min, l.popularity ?? 100),
|
||||
(min: number, l) => Math.min(min, l.popularity ?? 100),
|
||||
Infinity,
|
||||
);
|
||||
|
||||
@@ -264,8 +274,7 @@ export class AdminService {
|
||||
{
|
||||
type: 'game_complete',
|
||||
scoreTotal: score?.totalScore ?? 0,
|
||||
chainLength:
|
||||
score?.linkCount ?? Math.floor((game.chain as any[]).length / 2),
|
||||
chainLength: score?.linkCount ?? Math.floor(chain.length / 2),
|
||||
elapsedSeconds:
|
||||
score?.elapsedSeconds ??
|
||||
Math.floor(
|
||||
|
||||
@@ -11,7 +11,9 @@ export class AdminGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: { sub?: string } }>();
|
||||
const userId = request.user?.sub;
|
||||
if (!userId) {
|
||||
throw new ForbiddenException('Not authenticated');
|
||||
|
||||
@@ -13,7 +13,7 @@ export class AuthController {
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
return this.authService.register(dto, dto.rememberMe ?? false);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@@ -22,7 +22,8 @@ export class AuthController {
|
||||
@ApiBody({ type: LoginDto })
|
||||
login(
|
||||
@Request() req: { user: { id: string; email: string; username: string } },
|
||||
@Body() body: LoginDto,
|
||||
) {
|
||||
return this.authService.login(req.user);
|
||||
return this.authService.login(req.user, body.rememberMe ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.getOrThrow<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRATION', '1d') as any,
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
+28
-15
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, ConflictException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
@@ -13,26 +10,39 @@ export class AuthService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
private resolveExpiresIn(rememberMe: boolean): string {
|
||||
return rememberMe
|
||||
? this.configService.get<string>('JWT_LONG_EXPIRATION', '30d')
|
||||
: this.configService.get<string>('JWT_EXPIRATION', '1d');
|
||||
}
|
||||
|
||||
async validateUser(email: string, password: string) {
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
if (user && (await bcrypt.compare(password, user.password))) {
|
||||
const { password: _, ...result } = user;
|
||||
const { password: _password, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async login(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
login(
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
isAdmin?: boolean;
|
||||
},
|
||||
rememberMe = false,
|
||||
) {
|
||||
const payload = { sub: user.id, email: user.email };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
access_token: this.jwtService.sign(payload, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: this.resolveExpiresIn(rememberMe) as any,
|
||||
}),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -42,7 +52,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
async register(dto: RegisterDto, rememberMe = false) {
|
||||
const existingEmail = await this.usersService.findByEmail(dto.email);
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('Email already in use');
|
||||
@@ -64,7 +74,10 @@ export class AuthService {
|
||||
|
||||
const payload = { sub: user.id, email: user.email };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
access_token: this.jwtService.sign(payload, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: this.resolveExpiresIn(rememberMe) as any,
|
||||
}),
|
||||
user: { id: user.id, email: user.email, username: user.username },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, IsBoolean, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
@@ -12,4 +12,12 @@ export class LoginDto {
|
||||
@ApiProperty({ description: 'User password', example: 'securepass123' })
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Issue a long-lived token (Keep me signed in)',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({
|
||||
@@ -23,4 +30,12 @@ export class RegisterDto {
|
||||
@MinLength(8)
|
||||
@MaxLength(100)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Issue a long-lived token (Keep me signed in)',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
|
||||
handleRequest(_err: any, user: any) {
|
||||
handleRequest<TUser = unknown>(
|
||||
_err: unknown,
|
||||
user: TUser | false,
|
||||
): TUser | null {
|
||||
return user || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { ChatService } from './chat.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import type { JwtPayload } from '../auth/types/jwt-payload';
|
||||
import { getErrorMessage } from '../common/utils/error.util';
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
@@ -41,14 +43,14 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: AuthenticatedSocket) {
|
||||
handleConnection(client: AuthenticatedSocket) {
|
||||
try {
|
||||
const token = client.handshake.auth?.token;
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
const payload = this.jwtService.verify(token);
|
||||
const payload = this.jwtService.verify<JwtPayload>(token);
|
||||
client.data.user = { sub: payload.sub, email: payload.email };
|
||||
|
||||
const userId = payload.sub;
|
||||
@@ -63,7 +65,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
}
|
||||
}
|
||||
|
||||
async handleDisconnect(client: AuthenticatedSocket) {
|
||||
handleDisconnect(client: AuthenticatedSocket) {
|
||||
const userId = client.data?.user?.sub;
|
||||
if (userId) {
|
||||
const sockets = this.userSockets.get(userId);
|
||||
@@ -123,14 +125,15 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
});
|
||||
|
||||
return { success: true, messageId: message.id };
|
||||
} catch (error: any) {
|
||||
client.emit('message-error', { error: error.message });
|
||||
return { success: false, error: error.message };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
client.emit('message-error', { error: message });
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('typing')
|
||||
async handleTyping(
|
||||
handleTyping(
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
@MessageBody() data: { recipientId: string },
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === 'string') return error;
|
||||
return JSON.stringify(error);
|
||||
}
|
||||
@@ -150,16 +150,20 @@ export class DailyChallengesService {
|
||||
update: {
|
||||
movieAId: pair.movieA.id,
|
||||
movieATitle: movieA.title,
|
||||
movieAReleaseDate: movieA.release_date ?? null,
|
||||
movieBId: pair.movieB.id,
|
||||
movieBTitle: movieB.title,
|
||||
movieBReleaseDate: movieB.release_date ?? null,
|
||||
par: result.par,
|
||||
},
|
||||
create: {
|
||||
date,
|
||||
movieAId: pair.movieA.id,
|
||||
movieATitle: movieA.title,
|
||||
movieAReleaseDate: movieA.release_date ?? null,
|
||||
movieBId: pair.movieB.id,
|
||||
movieBTitle: movieB.title,
|
||||
movieBReleaseDate: movieB.release_date ?? null,
|
||||
par: result.par,
|
||||
difficulty,
|
||||
},
|
||||
@@ -193,16 +197,20 @@ export class DailyChallengesService {
|
||||
update: {
|
||||
movieAId: pair.movieA.id,
|
||||
movieATitle: movieA.title,
|
||||
movieAReleaseDate: movieA.release_date ?? null,
|
||||
movieBId: pair.movieB.id,
|
||||
movieBTitle: movieB.title,
|
||||
movieBReleaseDate: movieB.release_date ?? null,
|
||||
par: result.par,
|
||||
},
|
||||
create: {
|
||||
date,
|
||||
movieAId: pair.movieA.id,
|
||||
movieATitle: movieA.title,
|
||||
movieAReleaseDate: movieA.release_date ?? null,
|
||||
movieBId: pair.movieB.id,
|
||||
movieBTitle: movieB.title,
|
||||
movieBReleaseDate: movieB.release_date ?? null,
|
||||
par: result.par,
|
||||
difficulty,
|
||||
},
|
||||
@@ -269,16 +277,20 @@ export class DailyChallengesService {
|
||||
update: {
|
||||
movieAId: movieA.id,
|
||||
movieATitle: movieA.title,
|
||||
movieAReleaseDate: movieA.release_date ?? null,
|
||||
movieBId: movieB.id,
|
||||
movieBTitle: movieB.title,
|
||||
movieBReleaseDate: movieB.release_date ?? null,
|
||||
par,
|
||||
},
|
||||
create: {
|
||||
date,
|
||||
movieAId: movieA.id,
|
||||
movieATitle: movieA.title,
|
||||
movieAReleaseDate: movieA.release_date ?? null,
|
||||
movieBId: movieB.id,
|
||||
movieBTitle: movieB.title,
|
||||
movieBReleaseDate: movieB.release_date ?? null,
|
||||
par,
|
||||
difficulty,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { GameNightService } from './game-night.service';
|
||||
import type { JwtPayload } from '../auth/types/jwt-payload';
|
||||
import { getErrorMessage } from '../common/utils/error.util';
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
@@ -41,14 +43,14 @@ export class GameNightGateway
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: AuthenticatedSocket) {
|
||||
handleConnection(client: AuthenticatedSocket) {
|
||||
try {
|
||||
const token = client.handshake.auth?.token;
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
const payload = this.jwtService.verify(token);
|
||||
const payload = this.jwtService.verify<JwtPayload>(token);
|
||||
client.data.user = { sub: payload.sub, email: payload.email };
|
||||
this.logger.log(`Client connected: ${client.id} (user: ${payload.sub})`);
|
||||
} catch {
|
||||
@@ -87,29 +89,29 @@ export class GameNightGateway
|
||||
const timerKey = `${roomId}:${userId}`;
|
||||
this.disconnectTimers.set(
|
||||
timerKey,
|
||||
setTimeout(async () => {
|
||||
setTimeout(() => {
|
||||
this.disconnectTimers.delete(timerKey);
|
||||
// After 60s, mark as disconnected permanently
|
||||
// Check if room is now finished
|
||||
try {
|
||||
const check = await this.gameNightService.forfeitPlayer(
|
||||
roomId,
|
||||
userId,
|
||||
);
|
||||
if (check.finished) {
|
||||
this.server
|
||||
.to(`room:${roomId}`)
|
||||
.emit('room-finished', check.results);
|
||||
this.clearRoomTimeout(roomId);
|
||||
void (async () => {
|
||||
try {
|
||||
const check = await this.gameNightService.forfeitPlayer(
|
||||
roomId,
|
||||
userId,
|
||||
);
|
||||
if (check.finished) {
|
||||
this.server
|
||||
.to(`room:${roomId}`)
|
||||
.emit('room-finished', check.results);
|
||||
this.clearRoomTimeout(roomId);
|
||||
}
|
||||
} catch {
|
||||
// Player may have reconnected or already completed
|
||||
}
|
||||
} catch {
|
||||
// Player may have reconnected or already completed
|
||||
}
|
||||
})();
|
||||
}, 60000),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Disconnect cleanup failed: ${error.message}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Disconnect cleanup failed: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
this.logger.log(`Client disconnected: ${client.id} (user: ${userId})`);
|
||||
@@ -125,7 +127,7 @@ export class GameNightGateway
|
||||
const room = await this.gameNightService.getRoom(data.roomId);
|
||||
|
||||
// Verify user is a player in this room
|
||||
const isPlayer = room.players.some((p: any) => p.userId === userId);
|
||||
const isPlayer = room.players.some((p) => p.userId === userId);
|
||||
if (!isPlayer) {
|
||||
client.emit('error', {
|
||||
message: 'You are not a player in this room',
|
||||
@@ -151,8 +153,8 @@ export class GameNightGateway
|
||||
client.emit('room-state', { room });
|
||||
|
||||
this.logger.log(`User ${userId} joined room: ${data.roomId}`);
|
||||
} catch (error: any) {
|
||||
client.emit('error', { message: error.message });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +174,8 @@ export class GameNightGateway
|
||||
userId,
|
||||
isReady: player.isReady,
|
||||
});
|
||||
} catch (error: any) {
|
||||
client.emit('error', { message: error.message });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,25 +231,27 @@ export class GameNightGateway
|
||||
const timeoutMs = started.timeoutMinutes * 60 * 1000;
|
||||
this.timeouts.set(
|
||||
data.roomId,
|
||||
setTimeout(async () => {
|
||||
setTimeout(() => {
|
||||
this.timeouts.delete(data.roomId);
|
||||
try {
|
||||
const results = await this.gameNightService.handleTimeout(
|
||||
data.roomId,
|
||||
);
|
||||
this.server.to(socketRoom).emit('room-finished', results);
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Timeout handling failed for room ${data.roomId}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const results = await this.gameNightService.handleTimeout(
|
||||
data.roomId,
|
||||
);
|
||||
this.server.to(socketRoom).emit('room-finished', results);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Timeout handling failed for room ${data.roomId}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, timeoutMs),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Game night started: ${data.roomId}`);
|
||||
} catch (error: any) {
|
||||
client.emit('error', { message: error.message });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,8 +294,8 @@ export class GameNightGateway
|
||||
.emit('room-finished', result.results);
|
||||
this.clearRoomTimeout(data.roomId);
|
||||
}
|
||||
} catch (error: any) {
|
||||
client.emit('error', { message: error.message });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,8 +321,8 @@ export class GameNightGateway
|
||||
.emit('room-finished', result.results);
|
||||
this.clearRoomTimeout(data.roomId);
|
||||
}
|
||||
} catch (error: any) {
|
||||
client.emit('error', { message: error.message });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,8 +344,8 @@ export class GameNightGateway
|
||||
|
||||
client.leave(`room:${data.roomId}`);
|
||||
client.data.roomId = undefined;
|
||||
} catch (error: any) {
|
||||
client.emit('error', { message: error.message });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -291,8 +291,10 @@ export class GameNightService {
|
||||
|
||||
// Rank by score
|
||||
const byScore = [...players].sort((a, b) => {
|
||||
const aScore = (a.score as any)?.totalScore ?? 0;
|
||||
const bScore = (b.score as any)?.totalScore ?? 0;
|
||||
const aScore =
|
||||
(a.score as { totalScore?: number } | null)?.totalScore ?? 0;
|
||||
const bScore =
|
||||
(b.score as { totalScore?: number } | null)?.totalScore ?? 0;
|
||||
return bScore - aScore;
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ export class CreateGameDto {
|
||||
@MinLength(1)
|
||||
movieATitle: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Release date of movie A (YYYY-MM-DD)',
|
||||
example: '2010-07-16',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
movieAReleaseDate?: string;
|
||||
|
||||
@ApiProperty({ description: 'TMDB ID of movie B', example: 155 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@@ -25,6 +33,14 @@ export class CreateGameDto {
|
||||
@MinLength(1)
|
||||
movieBTitle: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Release date of movie B (YYYY-MM-DD)',
|
||||
example: '2008-07-18',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
movieBReleaseDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Daily challenge ID (if playing a daily challenge)',
|
||||
})
|
||||
|
||||
@@ -42,8 +42,10 @@ export class GamesService {
|
||||
data: {
|
||||
movieAId: dto.movieAId,
|
||||
movieATitle: dto.movieATitle,
|
||||
movieAReleaseDate: dto.movieAReleaseDate ?? null,
|
||||
movieBId: dto.movieBId,
|
||||
movieBTitle: dto.movieBTitle,
|
||||
movieBReleaseDate: dto.movieBReleaseDate ?? null,
|
||||
userId: userId || null,
|
||||
dailyChallengeId: dto.dailyChallengeId || null,
|
||||
},
|
||||
@@ -63,8 +65,10 @@ export class GamesService {
|
||||
id: true,
|
||||
movieAId: true,
|
||||
movieATitle: true,
|
||||
movieAReleaseDate: true,
|
||||
movieBId: true,
|
||||
movieBTitle: true,
|
||||
movieBReleaseDate: true,
|
||||
chain: true,
|
||||
score: true,
|
||||
hintsUsed: true,
|
||||
|
||||
@@ -18,6 +18,12 @@ export interface InternalSubmitScoreDto {
|
||||
difficulty?: string;
|
||||
}
|
||||
|
||||
interface PersistedScore {
|
||||
totalScore?: number;
|
||||
linkCount?: number;
|
||||
elapsedSeconds?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LeaderboardsService {
|
||||
private readonly logger = new Logger(LeaderboardsService.name);
|
||||
@@ -97,7 +103,11 @@ export class LeaderboardsService {
|
||||
difficulty?: string,
|
||||
) {
|
||||
const dateFilter = this.getDateFilter(period);
|
||||
const where: any = { ...dateFilter, gameMode: 'daily' };
|
||||
const where: {
|
||||
date?: Date | { gte: Date; lte: Date };
|
||||
gameMode: string;
|
||||
difficulty?: string;
|
||||
} = { ...dateFilter, gameMode: 'daily' };
|
||||
if (difficulty) {
|
||||
where.difficulty = difficulty;
|
||||
}
|
||||
@@ -195,7 +205,9 @@ export class LeaderboardsService {
|
||||
};
|
||||
}
|
||||
|
||||
const scores = games.map((g) => (g.score as any)?.totalScore ?? 0);
|
||||
const scores = games.map(
|
||||
(g) => (g.score as PersistedScore | null)?.totalScore ?? 0,
|
||||
);
|
||||
const totalScore = scores.reduce((a, b) => a + b, 0);
|
||||
|
||||
// Build date entries from completedAt for streak calculation
|
||||
@@ -219,12 +231,15 @@ export class LeaderboardsService {
|
||||
totalScore,
|
||||
currentStreak: calculateCurrentStreak(uniqueDateEntries, getUtcDate()),
|
||||
longestStreak: calculateLongestStreak(uniqueDateEntries),
|
||||
recentScores: games.slice(0, 10).map((g) => ({
|
||||
score: (g.score as any)?.totalScore ?? 0,
|
||||
chainLength: (g.score as any)?.linkCount ?? 0,
|
||||
timeSeconds: (g.score as any)?.elapsedSeconds ?? 0,
|
||||
date: g.completedAt ?? g.startedAt,
|
||||
})),
|
||||
recentScores: games.slice(0, 10).map((g) => {
|
||||
const s = g.score as PersistedScore | null;
|
||||
return {
|
||||
score: s?.totalScore ?? 0,
|
||||
chainLength: s?.linkCount ?? 0,
|
||||
timeSeconds: s?.elapsedSeconds ?? 0,
|
||||
date: g.completedAt ?? g.startedAt,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,19 @@ export class TmdbService {
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const status = error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { status?: number; data?: { status_message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const status =
|
||||
axiosError.response?.status ?? HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
const message =
|
||||
error.response?.data?.status_message || 'TMDB API request failed';
|
||||
this.logger.error(`TMDB request failed: ${path}`, error.message);
|
||||
axiosError.response?.data?.status_message ?? 'TMDB API request failed';
|
||||
this.logger.error(
|
||||
`TMDB request failed: ${path}`,
|
||||
axiosError.message ?? '',
|
||||
);
|
||||
throw new HttpException(message, status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationsGateway } from './notifications.gateway';
|
||||
import { PushService } from './push.service';
|
||||
import { getErrorMessage } from '../common/utils/error.util';
|
||||
|
||||
interface VersusAttemptMessage {
|
||||
creatorId: string;
|
||||
@@ -76,8 +77,10 @@ export class NotificationsConsumer {
|
||||
if (pushPayload && this.pushService.shouldSendPush(userId)) {
|
||||
this.pushService
|
||||
.sendPush(userId, pushPayload)
|
||||
.catch((err) =>
|
||||
this.logger.error(`Push notification failed: ${err.message}`),
|
||||
.catch((err: unknown) =>
|
||||
this.logger.error(
|
||||
`Push notification failed: ${getErrorMessage(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -105,8 +108,10 @@ export class NotificationsConsumer {
|
||||
url: `/versus/async/${msg.matchId}/leaderboard`,
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to process notification: ${error.message}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process notification: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,9 +134,9 @@ export class NotificationsConsumer {
|
||||
url: '/friends',
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process friend request notification: ${error.message}`,
|
||||
`Failed to process friend request notification: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -155,9 +160,9 @@ export class NotificationsConsumer {
|
||||
url: '/friends',
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process friend accepted notification: ${error.message}`,
|
||||
`Failed to process friend accepted notification: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -189,9 +194,9 @@ export class NotificationsConsumer {
|
||||
url,
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process friend challenge notification: ${error.message}`,
|
||||
`Failed to process friend challenge notification: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -215,9 +220,9 @@ export class NotificationsConsumer {
|
||||
url: `/friends/chat/${msg.senderId}`,
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process chat notification: ${error.message}`,
|
||||
`Failed to process chat notification: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { PresenceService } from './presence.service';
|
||||
import { FriendsService } from '../friends/friends.service';
|
||||
import type { JwtPayload } from '../auth/types/jwt-payload';
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
@@ -45,30 +46,31 @@ export class NotificationsGateway
|
||||
|
||||
onModuleInit() {
|
||||
// Subscribe to presence changes and notify friends
|
||||
this.presenceService.onPresenceChange(async (data) => {
|
||||
try {
|
||||
const friends = await this.friendsService.getFriends(data.userId);
|
||||
const event =
|
||||
data.status === 'online' ? 'friend-online' : 'friend-offline';
|
||||
this.presenceService.onPresenceChange((data) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const friends = await this.friendsService.getFriends(data.userId);
|
||||
const event =
|
||||
data.status === 'online' ? 'friend-online' : 'friend-offline';
|
||||
|
||||
for (const { friend } of friends) {
|
||||
const friendId = (friend as any).id;
|
||||
this.sendEventToUser(friendId, event, { userId: data.userId });
|
||||
for (const { friend } of friends) {
|
||||
this.sendEventToUser(friend.id, event, { userId: data.userId });
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
async handleConnection(client: AuthenticatedSocket) {
|
||||
try {
|
||||
const token = client.handshake.auth?.token;
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
const payload = this.jwtService.verify(token);
|
||||
const payload = this.jwtService.verify<JwtPayload>(token);
|
||||
client.data.user = { sub: payload.sub, email: payload.email };
|
||||
|
||||
const userId = payload.sub;
|
||||
@@ -82,12 +84,12 @@ export class NotificationsGateway
|
||||
|
||||
// Start heartbeat to refresh presence TTL
|
||||
if (!this.heartbeatIntervals.has(userId)) {
|
||||
const interval = setInterval(async () => {
|
||||
const interval = setInterval(() => {
|
||||
if (
|
||||
this.userSockets.has(userId) &&
|
||||
this.userSockets.get(userId)!.size > 0
|
||||
) {
|
||||
await this.presenceService.refreshPresence(userId);
|
||||
void this.presenceService.refreshPresence(userId);
|
||||
}
|
||||
}, 60_000);
|
||||
this.heartbeatIntervals.set(userId, interval);
|
||||
|
||||
@@ -13,9 +13,9 @@ export class PresenceService implements OnModuleDestroy {
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const redisConfig = {
|
||||
host: this.configService.get('REDIS_HOST', 'localhost'),
|
||||
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
|
||||
port: this.configService.get<number>('REDIS_PORT', 6379),
|
||||
password: this.configService.get('REDIS_PASSWORD'),
|
||||
password: this.configService.get<string>('REDIS_PASSWORD'),
|
||||
};
|
||||
|
||||
this.redis = new Redis(redisConfig);
|
||||
@@ -71,10 +71,14 @@ export class PresenceService implements OnModuleDestroy {
|
||||
onPresenceChange(
|
||||
callback: (data: { userId: string; status: string }) => void,
|
||||
) {
|
||||
this.subClient.subscribe('presence:changes');
|
||||
void this.subClient.subscribe('presence:changes');
|
||||
this.subClient.on('message', (_channel, message) => {
|
||||
try {
|
||||
callback(JSON.parse(message));
|
||||
const parsed = JSON.parse(message) as {
|
||||
userId: string;
|
||||
status: string;
|
||||
};
|
||||
callback(parsed);
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to parse presence change message', err);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import * as webPush from 'web-push';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsGateway } from './notifications.gateway';
|
||||
import { getErrorMessage } from '../common/utils/error.util';
|
||||
|
||||
@Injectable()
|
||||
export class PushService {
|
||||
@@ -14,9 +15,9 @@ export class PushService {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly notificationsGateway: NotificationsGateway,
|
||||
) {
|
||||
const publicKey = this.configService.get('VAPID_PUBLIC_KEY');
|
||||
const privateKey = this.configService.get('VAPID_PRIVATE_KEY');
|
||||
const subject = this.configService.get(
|
||||
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
|
||||
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
|
||||
const subject = this.configService.get<string>(
|
||||
'VAPID_SUBJECT',
|
||||
'mailto:admin@movieloop.app',
|
||||
);
|
||||
@@ -83,8 +84,12 @@ export class PushService {
|
||||
},
|
||||
payloadStr,
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
} catch (error) {
|
||||
const statusCode =
|
||||
error instanceof Error && 'statusCode' in error
|
||||
? (error as { statusCode?: number }).statusCode
|
||||
: undefined;
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
// Subscription no longer valid
|
||||
await this.prisma.pushSubscription.delete({
|
||||
where: { id: sub.id },
|
||||
@@ -92,7 +97,7 @@ export class PushService {
|
||||
this.logger.log(`Removed stale push subscription ${sub.id}`);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Push failed for subscription ${sub.id}: ${error.message}`,
|
||||
`Push failed for subscription ${sub.id}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +109,6 @@ export class PushService {
|
||||
}
|
||||
|
||||
getVapidPublicKey(): string | null {
|
||||
return this.configService.get('VAPID_PUBLIC_KEY') || null;
|
||||
return this.configService.get<string>('VAPID_PUBLIC_KEY') ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
IsUrl,
|
||||
} from 'class-validator';
|
||||
import { IsOptional, IsString, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { VersusAsyncService } from './versus-async.service';
|
||||
import { getErrorMessage } from '../common/utils/error.util';
|
||||
|
||||
@Injectable()
|
||||
export class AsyncExpirationService {
|
||||
@@ -15,8 +16,10 @@ export class AsyncExpirationService {
|
||||
if (count > 0) {
|
||||
this.logger.log(`Expired ${count} async versus matches`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to expire async matches: ${error.message}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to expire async matches: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,8 +111,10 @@ export class VersusAsyncService {
|
||||
attempt,
|
||||
movieAId: match.movieAId,
|
||||
movieATitle: match.movieATitle,
|
||||
movieAReleaseDate: match.movieAReleaseDate,
|
||||
movieBId: match.movieBId,
|
||||
movieBTitle: match.movieBTitle,
|
||||
movieBReleaseDate: match.movieBReleaseDate,
|
||||
chainLength: match.chainLength,
|
||||
};
|
||||
}
|
||||
@@ -228,7 +230,9 @@ export class VersusAsyncService {
|
||||
expiresAt: match.expiresAt,
|
||||
status: match.status,
|
||||
movieATitle: match.movieATitle,
|
||||
movieAReleaseDate: match.movieAReleaseDate,
|
||||
movieBTitle: match.movieBTitle,
|
||||
movieBReleaseDate: match.movieBReleaseDate,
|
||||
},
|
||||
creator: match.player1,
|
||||
leaderboard: ranked,
|
||||
|
||||
@@ -82,8 +82,10 @@ export class VersusMatchService {
|
||||
player1Id,
|
||||
movieAId: pair.movieA.id,
|
||||
movieATitle: movieA.title,
|
||||
movieAReleaseDate: movieA.release_date ?? null,
|
||||
movieBId: pair.movieB.id,
|
||||
movieBTitle: movieB.title,
|
||||
movieBReleaseDate: movieB.release_date ?? null,
|
||||
difficulty: analysisResult.difficulty,
|
||||
status: 'waiting',
|
||||
lobbyName: lobbyName || null,
|
||||
@@ -102,15 +104,19 @@ export class VersusMatchService {
|
||||
return { ...rest, hasPassword: !!_ph };
|
||||
}
|
||||
|
||||
// Don't expose movie titles or password hash in response for waiting sync matches
|
||||
// Don't expose movie titles or release dates or password hash in response for waiting sync matches
|
||||
const {
|
||||
passwordHash: _ph2,
|
||||
movieATitle: _mAt,
|
||||
movieBTitle: _mBt,
|
||||
movieAReleaseDate: _mArd,
|
||||
movieBReleaseDate: _mBrd,
|
||||
...rest
|
||||
} = match;
|
||||
void _mAt;
|
||||
void _mBt;
|
||||
void _mArd;
|
||||
void _mBrd;
|
||||
return { ...rest, hasPassword: !!_ph2 };
|
||||
}
|
||||
|
||||
@@ -199,11 +205,15 @@ export class VersusMatchService {
|
||||
passwordHash,
|
||||
movieATitle: _maT,
|
||||
movieBTitle: _mbT,
|
||||
movieAReleaseDate: _maRd,
|
||||
movieBReleaseDate: _mbRd,
|
||||
_count,
|
||||
...rest
|
||||
}) => {
|
||||
void _maT;
|
||||
void _mbT;
|
||||
void _maRd;
|
||||
void _mbRd;
|
||||
return {
|
||||
...rest,
|
||||
hasPassword: !!passwordHash,
|
||||
@@ -253,11 +263,15 @@ export class VersusMatchService {
|
||||
passwordHash,
|
||||
movieATitle: _maT2,
|
||||
movieBTitle: _mbT2,
|
||||
movieAReleaseDate: _maRd2,
|
||||
movieBReleaseDate: _mbRd2,
|
||||
_count,
|
||||
...rest
|
||||
}) => {
|
||||
void _maT2;
|
||||
void _mbT2;
|
||||
void _maRd2;
|
||||
void _mbRd2;
|
||||
return {
|
||||
...rest,
|
||||
hasPassword: !!passwordHash,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { VersusService } from './versus.service';
|
||||
import type { JwtPayload } from '../auth/types/jwt-payload';
|
||||
import { getErrorMessage } from '../common/utils/error.util';
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
@@ -37,14 +39,14 @@ export class VersusGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: AuthenticatedSocket) {
|
||||
handleConnection(client: AuthenticatedSocket) {
|
||||
try {
|
||||
const token = client.handshake.auth?.token;
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
const payload = this.jwtService.verify(token);
|
||||
const payload = this.jwtService.verify<JwtPayload>(token);
|
||||
client.data.user = { sub: payload.sub, email: payload.email };
|
||||
this.logger.log(`Client connected: ${client.id} (user: ${payload.sub})`);
|
||||
} catch {
|
||||
@@ -83,8 +85,8 @@ export class VersusGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
}
|
||||
}
|
||||
// If playing, match continues — player can reconnect
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Disconnect cleanup failed: ${error.message}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Disconnect cleanup failed: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
this.logger.log(`Client disconnected: ${client.id} (user: ${userId})`);
|
||||
@@ -127,8 +129,8 @@ export class VersusGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
}
|
||||
|
||||
this.logger.log(`User ${userId} joined lobby: ${data.matchId}`);
|
||||
} catch (error: any) {
|
||||
client.emit('error', { message: error.message });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,8 +181,8 @@ export class VersusGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
});
|
||||
|
||||
this.logger.log(`Match started: ${data.matchId}`);
|
||||
} catch (error: any) {
|
||||
client.emit('error', { message: error.message });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,8 +217,8 @@ export class VersusGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
.to(`match:${data.matchId}`)
|
||||
.emit('opponent-finished', { userId });
|
||||
}
|
||||
} catch (error: any) {
|
||||
client.emit('error', { message: error.message });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,8 +243,8 @@ export class VersusGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
|
||||
client.leave(`match:${data.matchId}`);
|
||||
client.data.matchId = undefined;
|
||||
} catch (error: any) {
|
||||
client.emit('error', { message: error.message });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user