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:
2026-05-08 16:40:00 -07:00
parent 8b2812ca10
commit f0def62eef
32 changed files with 348 additions and 164 deletions
+1
View File
@@ -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
+9
View File
@@ -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;
+6
View File
@@ -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")
+4 -1
View File
@@ -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);
}
+18 -9
View File
@@ -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(
+3 -1
View File
@@ -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');
+3 -2
View File
@@ -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);
}
}
-3
View File
@@ -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
View File
@@ -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 },
};
}
+10 -2
View File
@@ -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;
}
+17 -2
View File
@@ -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;
}
+4 -1
View File
@@ -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;
}
}
+6
View File
@@ -0,0 +1,6 @@
export interface JwtPayload {
sub: string;
email: string;
iat?: number;
exp?: number;
}
+11 -8
View File
@@ -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 },
) {
+5
View File
@@ -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,
},
+49 -45
View File
@@ -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) });
}
}
+4 -2
View File
@@ -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;
});
+16
View File
@@ -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)',
})
+4
View File
@@ -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,
+23 -8
View File
@@ -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,
};
}),
};
}
+12 -4
View File
@@ -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);
}
}
+17 -12
View File
@@ -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)}`,
);
}
}
+17 -15
View File
@@ -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);
+8 -4
View File
@@ -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);
}
+12 -7
View File
@@ -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 -7
View File
@@ -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 {
+5 -2
View File
@@ -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)}`,
);
}
}
}
+4
View File
@@ -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,
+15 -1
View File
@@ -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,
+15 -13
View File
@@ -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) });
}
}