Skip to content
Open
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 src/puzzles/dto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export * from './create-puzzle.dto';
export * from './update-puzzle.dto';
export * from './search-puzzle.dto';
export * from './bulk-operations.dto';
export * from './submit-solution.dto';
export * from './submission-result.dto';
115 changes: 115 additions & 0 deletions src/puzzles/dto/submission-result.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { SolutionAttemptStatus } from '../entities/puzzle-solution-attempt.entity';

/**
* Reward breakdown returned with a successful (correct) submission.
*/
export class RewardBreakdownDto {
@ApiProperty({ example: 350 })
baseScore: number;

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

@ApiProperty({ example: 30 })
streakBonus: number;

@ApiProperty({ example: -20 })
hintPenalty: number;

@ApiPropertyOptional({ example: 100 })
firstSolveBonus?: number;

@ApiProperty({ example: 410 })
totalScore: number;

@ApiProperty({ example: 205 })
totalExperience: number;

@ApiProperty({ example: ['speed_demon', 'independent_thinker'] })
achievements: string[];
}

/**
* Response returned from POST /puzzles/:id/submit
*/
export class SubmissionResultDto {
@ApiProperty({ example: '3fa85f64-5717-4562-b3fc-2c963f66afa6' })
submissionId: string;

@ApiProperty({ enum: SolutionAttemptStatus, example: SolutionAttemptStatus.CORRECT })
status: SolutionAttemptStatus;

@ApiProperty({ example: true })
isCorrect: boolean;

@ApiProperty({
description: 'Elapsed time in seconds between when the puzzle was started and when it was submitted.',
example: 45,
})
timeTakenSeconds: number;

@ApiPropertyOptional({
type: RewardBreakdownDto,
description: 'Only present when status is "correct".',
})
rewards?: RewardBreakdownDto;

@ApiPropertyOptional({
description: 'Human-readable explanation of why the submission was rejected.',
example: 'Time limit exceeded',
})
message?: string;

@ApiPropertyOptional({
description: 'Explanation of the correct answer (shown after submission).',
example: 'The answer is B because...',
})
explanation?: string;
}

/**
* A single entry in a submission history list.
*/
export class SubmissionHistoryItemDto {
@ApiProperty()
id: string;

@ApiProperty()
puzzleId: string;

@ApiProperty({ enum: SolutionAttemptStatus })
status: SolutionAttemptStatus;

@ApiProperty()
timeTakenSeconds: number;

@ApiProperty()
scoreAwarded: number;

@ApiProperty()
hintsUsed: number;

@ApiProperty()
createdAt: Date;
}

/**
* Paginated list of submission history entries.
*/
export class SubmissionHistoryDto {
@ApiProperty({ type: [SubmissionHistoryItemDto] })
items: SubmissionHistoryItemDto[];

@ApiProperty({ example: 42 })
total: number;

@ApiProperty({ example: 1 })
page: number;

@ApiProperty({ example: 20 })
limit: number;

@ApiProperty({ example: 3 })
totalPages: number;
}
65 changes: 65 additions & 0 deletions src/puzzles/dto/submit-solution.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
IsNotEmpty,
IsString,
IsUUID,
IsISO8601,
IsOptional,
IsInt,
Min,
Max,
IsObject,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

/**
* DTO for submitting a puzzle solution answer.
* The `nonce` must be a UUID v4 generated by the client and used only once
* (prevents replay attacks).
*/
export class SubmitSolutionDto {
@ApiProperty({
description:
'The player\'s answer. Shape depends on puzzle content.type ' +
'(e.g. index for multiple-choice, string for fill-blank, object for logic-grid).',
example: 2,
})
@IsNotEmpty()
answer: any;

@ApiProperty({
description:
'Unique UUID v4 generated client-side for this submission. ' +
'Re-using the same nonce will result in HTTP 409 (replay attack prevention).',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@IsUUID(4)
nonce: string;

@ApiProperty({
description:
'ISO-8601 timestamp of when the player started (received) this puzzle. ' +
'Used server-side to compute elapsed time and enforce the time limit.',
example: '2026-02-20T20:00:00.000Z',
})
@IsISO8601()
sessionStartedAt: string;

@ApiPropertyOptional({
description: 'Number of hints the player used during this attempt.',
example: 1,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(10)
hintsUsed?: number;

@ApiPropertyOptional({
description: 'Optional client-side metadata (user-agent, device info, etc.).',
example: { platform: 'web', appVersion: '1.2.3' },
})
@IsOptional()
@IsObject()
clientMetadata?: Record<string, any>;
}
143 changes: 143 additions & 0 deletions src/puzzles/entities/puzzle-solution-attempt.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';

/**
* Status of a puzzle solution submission attempt
*/
export enum SolutionAttemptStatus {
CORRECT = 'correct',
INCORRECT = 'incorrect',
TIMEOUT = 'timeout',
FRAUD_DETECTED = 'fraud_detected',
RATE_LIMITED = 'rate_limited',
}

/**
* Tracks every puzzle solution submission attempt.
* Answers are stored as SHA-256 hashes β€” plaintext answers are never persisted.
* The `nonce` column has a unique constraint to prevent replay attacks.
*/
@Entity('puzzle_solution_attempts')
@Unique(['nonce']) // Anti-replay: nonce must be globally unique
@Index(['userId', 'puzzleId'])
@Index(['userId', 'createdAt'])
@Index(['puzzleId', 'status'])
export class PuzzleSolutionAttempt {
@PrimaryGeneratedColumn('uuid')
id: string;

// ──────────────── Identifiers ────────────────

@Column({ type: 'uuid' })
@Index()
userId: string;

@Column({ type: 'uuid' })
@Index()
puzzleId: string;

// ──────────────── Anti-Replay ────────────────

/**
* Client-generated UUID v4 that must be unique per submission.
* Stored to detect replay attacks (re-submitting same request).
*/
@Column({ type: 'varchar', length: 36 })
@Index()
nonce: string;

// ──────────────── Verification ────────────────

/**
* SHA-256 hash of the normalised submitted answer.
* Never store the plaintext answer.
*/
@Column({ type: 'varchar', length: 64 })
answerHash: string;

@Column({
type: 'enum',
enum: SolutionAttemptStatus,
default: SolutionAttemptStatus.INCORRECT,
})
@Index()
status: SolutionAttemptStatus;

// ──────────────── Timing ────────────────

/**
* ISO timestamp sent by the client indicating when they received
* (started) the puzzle. Used for time-limit and fraud validation.
*/
@Column({ type: 'timestamp with time zone' })
sessionStartedAt: Date;

/**
* Elapsed seconds between sessionStartedAt and submission time.
* Computed server-side β€” not trusted from the client.
*/
@Column({ type: 'int', default: 0 })
timeTakenSeconds: number;

// ──────────────── Performance ────────────────

@Column({ type: 'int', default: 0 })
hintsUsed: number;

// ──────────────── Rewards ────────────────

@Column({ type: 'int', default: 0 })
scoreAwarded: number;

/**
* Detailed reward breakdown: base points, bonuses, penalties, achievements.
*/
@Column({ type: 'jsonb', default: {} })
rewardData: {
baseScore?: number;
timeBonus?: number;
streakBonus?: number;
hintPenalty?: number;
firstSolveBonus?: number;
achievements?: string[];
totalExperience?: number;
};

// ──────────────── Fraud / Anti-Cheat ────────────────

/**
* Fraud/anti-cheat flags recorded during processing.
* Submission may still be stored when fraud is detected for audit purposes.
*/
@Column({ type: 'jsonb', default: {} })
fraudFlags: {
tooFast?: boolean;
minExpectedSeconds?: number;
actualSeconds?: number;
violationTypes?: string[];
riskScore?: number;
};

// ──────────────── Request Metadata ────────────────

@Column({ type: 'varchar', length: 45, nullable: true })
ipAddress?: string;

/**
* Extensible metadata (user-agent, device fingerprint, geo, etc.)
*/
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;

@CreateDateColumn()
@Index()
createdAt: Date;
}
Loading