Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { GeolocationMiddleware } from './common/middleware/geolocation.middlewar
import { HealthModule } from './health/health.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { ChallengeAttemptModule } from './challenge-attempt/challenge-attempt.module';
import { GameSessionsModule } from './game-sessions/game-sessions.module';

// const ENV = process.env.NODE_ENV;
// console.log('NODE_ENV:', process.env.NODE_ENV);
Expand Down Expand Up @@ -121,6 +122,7 @@ import { ChallengeAttemptModule } from './challenge-attempt/challenge-attempt.mo
}),
HealthModule,
ChallengeAttemptModule,
GameSessionsModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
168 changes: 168 additions & 0 deletions backend/src/game-sessions/controllers/game-sessions.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import {
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { GameSessionsService } from '../providers/game-sessions.service';
import { CreateGameSessionDto } from '../dtos/create-game-session.dto';
import { UpdateGameSessionStatusDto } from '../dtos/update-game-session-status.dto';
import { GameSessionResponseDto } from '../dtos/game-session-response.dto';
import { GameSession } from '../entities/game-session.entity';
import { ActiveUser } from '../../auth/decorators/activeUser.decorator';
import { ActiveUserData } from '../../auth/interfaces/activeInterface';

@ApiTags('game-sessions')
@Controller('game-sessions')
export class GameSessionsController {
constructor(private readonly gameSessionsService: GameSessionsService) {}

// ─────────────────────────────────────────────────────────────────────────────
// POST /game-sessions
// ─────────────────────────────────────────────────────────────────────────────

@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Create a new game session',
description:
'Creates a game session in CREATED state for the authenticated user (or a guest). ' +
'Call PATCH /game-sessions/:id/status with status=ACTIVE to start it.',
})
@ApiResponse({
status: 201,
description: 'Game session created successfully',
type: GameSessionResponseDto,
})
@ApiResponse({ status: 400, description: 'Validation failed' })
async create(
@Body() dto: CreateGameSessionDto,
@ActiveUser() activeUser: ActiveUserData | undefined,
): Promise<GameSession> {
const userId = activeUser?.sub ?? null;
return this.gameSessionsService.create(dto, userId);
}

// ─────────────────────────────────────────────────────────────────────────────
// GET /game-sessions → list all sessions for the current user
// ─────────────────────────────────────────────────────────────────────────────

@Get()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'List all game sessions for the authenticated user',
description: 'Returns sessions ordered by creation date, most recent first.',
})
@ApiResponse({
status: 200,
description: 'Sessions retrieved successfully',
type: [GameSessionResponseDto],
})
async findAll(
@ActiveUser() activeUser: ActiveUserData,
): Promise<GameSession[]> {
return this.gameSessionsService.findAllByUser(activeUser.sub);
}

// ─────────────────────────────────────────────────────────────────────────────
// GET /game-sessions/active
// ─────────────────────────────────────────────────────────────────────────────

@Get('active')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get the currently active session for the authenticated user',
description: 'Returns the single ACTIVE session, or null if none exists.',
})
@ApiResponse({
status: 200,
description: 'Active session (or null)',
type: GameSessionResponseDto,
})
async findActive(
@ActiveUser() activeUser: ActiveUserData,
): Promise<GameSession | null> {
return this.gameSessionsService.findActiveSession(activeUser.sub);
}

// ─────────────────────────────────────────────────────────────────────────────
// GET /game-sessions/:id
// ─────────────────────────────────────────────────────────────────────────────

@Get(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Get a game session by ID' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiQuery({
name: 'guestId',
required: false,
description: 'Guest identifier (for unauthenticated sessions)',
})
@ApiResponse({
status: 200,
description: 'Session found',
type: GameSessionResponseDto,
})
@ApiResponse({ status: 403, description: 'Forbidden – not session owner' })
@ApiResponse({ status: 404, description: 'Session not found' })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@ActiveUser() activeUser: ActiveUserData | undefined,
@Query('guestId') guestId?: string,
): Promise<GameSession> {
const userId = activeUser?.sub ?? null;
return this.gameSessionsService.findAndVerifyOwnership(id, userId, guestId);
}

// ─────────────────────────────────────────────────────────────────────────────
// PATCH /game-sessions/:id/status
// ─────────────────────────────────────────────────────────────────────────────

@Patch(':id/status')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Update the status of a game session',
description:
'Validates the requested status transition according to the state machine ' +
'and applies it. Invalid transitions return HTTP 400.',
})
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiQuery({
name: 'guestId',
required: false,
description: 'Guest identifier (for unauthenticated sessions)',
})
@ApiResponse({
status: 200,
description: 'Status updated successfully',
type: GameSessionResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid status transition or validation error',
})
@ApiResponse({ status: 403, description: 'Forbidden – not session owner' })
@ApiResponse({ status: 404, description: 'Session not found' })
async updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateGameSessionStatusDto,
@ActiveUser() activeUser: ActiveUserData | undefined,
@Query('guestId') guestId?: string,
): Promise<GameSession> {
const userId = activeUser?.sub ?? null;
return this.gameSessionsService.updateStatus(id, dto, userId, guestId);
}
}
55 changes: 55 additions & 0 deletions backend/src/game-sessions/dtos/create-game-session.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { PuzzleDifficulty } from '../../puzzles/enums/puzzle-difficulty.enum';

export class CreateGameSessionDto {
/**
* Optional guest identifier for unauthenticated sessions.
* If not provided the session will be tied to the authenticated user.
*/
@ApiPropertyOptional({
description: 'Guest ID for unauthenticated sessions',
example: 'guest-abc123',
})
@IsOptional()
@IsString()
guestId?: string;

@ApiPropertyOptional({
description: 'Difficulty level for the session',
enum: PuzzleDifficulty,
example: PuzzleDifficulty.INTERMEDIATE,
})
@IsOptional()
@IsEnum(PuzzleDifficulty)
difficulty?: PuzzleDifficulty;

@ApiPropertyOptional({
description: 'Category IDs or slugs to include in this session',
example: ['coding', 'logic'],
type: [String],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
selectedCategories?: string[];

@ApiProperty({
description: 'Number of challenges to include in the session',
example: 10,
minimum: 1,
maximum: 100,
})
@IsInt()
@Min(1)
@Max(100)
challengeCount: number;
}
51 changes: 51 additions & 0 deletions backend/src/game-sessions/dtos/game-session-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { GameSessionStatus } from '../enums/game-session-status.enum';
import { PuzzleDifficulty } from '../../puzzles/enums/puzzle-difficulty.enum';

export class GameSessionResponseDto {
@ApiProperty({ example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
id: string;

@ApiPropertyOptional({ example: 'user-uuid', nullable: true })
userId: string | null;

@ApiPropertyOptional({ example: 'guest-abc123', nullable: true })
guestId: string | null;

@ApiProperty({ enum: GameSessionStatus, example: GameSessionStatus.ACTIVE })
status: GameSessionStatus;

@ApiPropertyOptional({ enum: PuzzleDifficulty, nullable: true })
difficulty: PuzzleDifficulty | null;

@ApiPropertyOptional({
type: [String],
example: ['coding', 'logic'],
nullable: true,
})
selectedCategories: string[] | null;

@ApiProperty({ example: 10 })
challengeCount: number;

@ApiProperty({ example: 2 })
currentChallenge: number;

@ApiProperty({ example: 300 })
score: number;

@ApiProperty({ example: 50 })
xpEarned: number;

@ApiPropertyOptional({ example: '2026-08-19T12:00:00.000Z', nullable: true })
startedAt: Date | null;

@ApiPropertyOptional({ example: '2026-08-19T12:15:00.000Z', nullable: true })
completedAt: Date | null;

@ApiProperty({ example: '2026-08-19T11:59:00.000Z' })
createdAt: Date;

@ApiProperty({ example: '2026-08-19T12:00:00.000Z' })
updatedAt: Date;
}
37 changes: 37 additions & 0 deletions backend/src/game-sessions/dtos/update-game-session-status.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsInt, IsOptional, Min } from 'class-validator';
import { GameSessionStatus } from '../enums/game-session-status.enum';

export class UpdateGameSessionStatusDto {
@ApiProperty({
description: 'The target status for this session',
enum: GameSessionStatus,
example: GameSessionStatus.ACTIVE,
})
@IsEnum(GameSessionStatus)
status: GameSessionStatus;

/**
* When completing a session, optionally supply the final score and XP.
* The service will ignore these values for non-terminal transitions.
*/
@ApiPropertyOptional({
description: 'Final score (used when transitioning to COMPLETED)',
example: 850,
minimum: 0,
})
@IsOptional()
@IsInt()
@Min(0)
score?: number;

@ApiPropertyOptional({
description: 'XP earned (used when transitioning to COMPLETED)',
example: 120,
minimum: 0,
})
@IsOptional()
@IsInt()
@Min(0)
xpEarned?: number;
}
Loading
Loading