Skip to content

Commit b50f0ca

Browse files
authored
Merge pull request #171 from Robinsonchiziterem/feature/puzzle-submission-api-141
feat(puzzles): implement puzzle solution submission and verification …
2 parents e490890 + 22800e8 commit b50f0ca

9 files changed

Lines changed: 1424 additions & 11 deletions

.github/workflows/ci-cd.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ jobs:
105105

106106
services:
107107
postgres:
108-
image: postgres:${{ env.POSTGRES_VERSION }}
108+
image: postgres:14
109109
env:
110110
POSTGRES_USER: test_user
111111
POSTGRES_PASSWORD: test_password
@@ -177,7 +177,7 @@ jobs:
177177

178178
services:
179179
postgres:
180-
image: postgres:${{ env.POSTGRES_VERSION }}
180+
image: postgres:14
181181
env:
182182
POSTGRES_USER: test_user
183183
POSTGRES_PASSWORD: test_password
@@ -258,7 +258,7 @@ jobs:
258258

259259
services:
260260
postgres:
261-
image: postgres:${{ env.POSTGRES_VERSION }}
261+
image: postgres:14
262262
env:
263263
POSTGRES_USER: perf_user
264264
POSTGRES_PASSWORD: perf_password

src/puzzles/dto/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ export * from './create-puzzle.dto';
22
export * from './update-puzzle.dto';
33
export * from './search-puzzle.dto';
44
export * from './bulk-operations.dto';
5+
export * from './submit-solution.dto';
6+
export * from './submission-result.dto';
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { SolutionAttemptStatus } from '../entities/puzzle-solution-attempt.entity';
3+
4+
/**
5+
* Reward breakdown returned with a successful (correct) submission.
6+
*/
7+
export class RewardBreakdownDto {
8+
@ApiProperty({ example: 350 })
9+
baseScore: number;
10+
11+
@ApiProperty({ example: 50 })
12+
timeBonus: number;
13+
14+
@ApiProperty({ example: 30 })
15+
streakBonus: number;
16+
17+
@ApiProperty({ example: -20 })
18+
hintPenalty: number;
19+
20+
@ApiPropertyOptional({ example: 100 })
21+
firstSolveBonus?: number;
22+
23+
@ApiProperty({ example: 410 })
24+
totalScore: number;
25+
26+
@ApiProperty({ example: 205 })
27+
totalExperience: number;
28+
29+
@ApiProperty({ example: ['speed_demon', 'independent_thinker'] })
30+
achievements: string[];
31+
}
32+
33+
/**
34+
* Response returned from POST /puzzles/:id/submit
35+
*/
36+
export class SubmissionResultDto {
37+
@ApiProperty({ example: '3fa85f64-5717-4562-b3fc-2c963f66afa6' })
38+
submissionId: string;
39+
40+
@ApiProperty({ enum: SolutionAttemptStatus, example: SolutionAttemptStatus.CORRECT })
41+
status: SolutionAttemptStatus;
42+
43+
@ApiProperty({ example: true })
44+
isCorrect: boolean;
45+
46+
@ApiProperty({
47+
description: 'Elapsed time in seconds between when the puzzle was started and when it was submitted.',
48+
example: 45,
49+
})
50+
timeTakenSeconds: number;
51+
52+
@ApiPropertyOptional({
53+
type: RewardBreakdownDto,
54+
description: 'Only present when status is "correct".',
55+
})
56+
rewards?: RewardBreakdownDto;
57+
58+
@ApiPropertyOptional({
59+
description: 'Human-readable explanation of why the submission was rejected.',
60+
example: 'Time limit exceeded',
61+
})
62+
message?: string;
63+
64+
@ApiPropertyOptional({
65+
description: 'Explanation of the correct answer (shown after submission).',
66+
example: 'The answer is B because...',
67+
})
68+
explanation?: string;
69+
}
70+
71+
/**
72+
* A single entry in a submission history list.
73+
*/
74+
export class SubmissionHistoryItemDto {
75+
@ApiProperty()
76+
id: string;
77+
78+
@ApiProperty()
79+
puzzleId: string;
80+
81+
@ApiProperty({ enum: SolutionAttemptStatus })
82+
status: SolutionAttemptStatus;
83+
84+
@ApiProperty()
85+
timeTakenSeconds: number;
86+
87+
@ApiProperty()
88+
scoreAwarded: number;
89+
90+
@ApiProperty()
91+
hintsUsed: number;
92+
93+
@ApiProperty()
94+
createdAt: Date;
95+
}
96+
97+
/**
98+
* Paginated list of submission history entries.
99+
*/
100+
export class SubmissionHistoryDto {
101+
@ApiProperty({ type: [SubmissionHistoryItemDto] })
102+
items: SubmissionHistoryItemDto[];
103+
104+
@ApiProperty({ example: 42 })
105+
total: number;
106+
107+
@ApiProperty({ example: 1 })
108+
page: number;
109+
110+
@ApiProperty({ example: 20 })
111+
limit: number;
112+
113+
@ApiProperty({ example: 3 })
114+
totalPages: number;
115+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
IsNotEmpty,
3+
IsString,
4+
IsUUID,
5+
IsISO8601,
6+
IsOptional,
7+
IsInt,
8+
Min,
9+
Max,
10+
IsObject,
11+
} from 'class-validator';
12+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
13+
14+
/**
15+
* DTO for submitting a puzzle solution answer.
16+
* The `nonce` must be a UUID v4 generated by the client and used only once
17+
* (prevents replay attacks).
18+
*/
19+
export class SubmitSolutionDto {
20+
@ApiProperty({
21+
description:
22+
'The player\'s answer. Shape depends on puzzle content.type ' +
23+
'(e.g. index for multiple-choice, string for fill-blank, object for logic-grid).',
24+
example: 2,
25+
})
26+
@IsNotEmpty()
27+
answer: any;
28+
29+
@ApiProperty({
30+
description:
31+
'Unique UUID v4 generated client-side for this submission. ' +
32+
'Re-using the same nonce will result in HTTP 409 (replay attack prevention).',
33+
example: '550e8400-e29b-41d4-a716-446655440000',
34+
})
35+
@IsUUID(4)
36+
nonce: string;
37+
38+
@ApiProperty({
39+
description:
40+
'ISO-8601 timestamp of when the player started (received) this puzzle. ' +
41+
'Used server-side to compute elapsed time and enforce the time limit.',
42+
example: '2026-02-20T20:00:00.000Z',
43+
})
44+
@IsISO8601()
45+
sessionStartedAt: string;
46+
47+
@ApiPropertyOptional({
48+
description: 'Number of hints the player used during this attempt.',
49+
example: 1,
50+
default: 0,
51+
})
52+
@IsOptional()
53+
@IsInt()
54+
@Min(0)
55+
@Max(10)
56+
hintsUsed?: number;
57+
58+
@ApiPropertyOptional({
59+
description: 'Optional client-side metadata (user-agent, device info, etc.).',
60+
example: { platform: 'web', appVersion: '1.2.3' },
61+
})
62+
@IsOptional()
63+
@IsObject()
64+
clientMetadata?: Record<string, any>;
65+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
CreateDateColumn,
6+
Index,
7+
ManyToOne,
8+
JoinColumn,
9+
Unique,
10+
} from 'typeorm';
11+
12+
/**
13+
* Status of a puzzle solution submission attempt
14+
*/
15+
export enum SolutionAttemptStatus {
16+
CORRECT = 'correct',
17+
INCORRECT = 'incorrect',
18+
TIMEOUT = 'timeout',
19+
FRAUD_DETECTED = 'fraud_detected',
20+
RATE_LIMITED = 'rate_limited',
21+
}
22+
23+
/**
24+
* Tracks every puzzle solution submission attempt.
25+
* Answers are stored as SHA-256 hashes — plaintext answers are never persisted.
26+
* The `nonce` column has a unique constraint to prevent replay attacks.
27+
*/
28+
@Entity('puzzle_solution_attempts')
29+
@Unique(['nonce']) // Anti-replay: nonce must be globally unique
30+
@Index(['userId', 'puzzleId'])
31+
@Index(['userId', 'createdAt'])
32+
@Index(['puzzleId', 'status'])
33+
export class PuzzleSolutionAttempt {
34+
@PrimaryGeneratedColumn('uuid')
35+
id: string;
36+
37+
// ──────────────── Identifiers ────────────────
38+
39+
@Column({ type: 'uuid' })
40+
@Index()
41+
userId: string;
42+
43+
@Column({ type: 'uuid' })
44+
@Index()
45+
puzzleId: string;
46+
47+
// ──────────────── Anti-Replay ────────────────
48+
49+
/**
50+
* Client-generated UUID v4 that must be unique per submission.
51+
* Stored to detect replay attacks (re-submitting same request).
52+
*/
53+
@Column({ type: 'varchar', length: 36 })
54+
@Index()
55+
nonce: string;
56+
57+
// ──────────────── Verification ────────────────
58+
59+
/**
60+
* SHA-256 hash of the normalised submitted answer.
61+
* Never store the plaintext answer.
62+
*/
63+
@Column({ type: 'varchar', length: 64 })
64+
answerHash: string;
65+
66+
@Column({
67+
type: 'enum',
68+
enum: SolutionAttemptStatus,
69+
default: SolutionAttemptStatus.INCORRECT,
70+
})
71+
@Index()
72+
status: SolutionAttemptStatus;
73+
74+
// ──────────────── Timing ────────────────
75+
76+
/**
77+
* ISO timestamp sent by the client indicating when they received
78+
* (started) the puzzle. Used for time-limit and fraud validation.
79+
*/
80+
@Column({ type: 'timestamp with time zone' })
81+
sessionStartedAt: Date;
82+
83+
/**
84+
* Elapsed seconds between sessionStartedAt and submission time.
85+
* Computed server-side — not trusted from the client.
86+
*/
87+
@Column({ type: 'int', default: 0 })
88+
timeTakenSeconds: number;
89+
90+
// ──────────────── Performance ────────────────
91+
92+
@Column({ type: 'int', default: 0 })
93+
hintsUsed: number;
94+
95+
// ──────────────── Rewards ────────────────
96+
97+
@Column({ type: 'int', default: 0 })
98+
scoreAwarded: number;
99+
100+
/**
101+
* Detailed reward breakdown: base points, bonuses, penalties, achievements.
102+
*/
103+
@Column({ type: 'jsonb', default: {} })
104+
rewardData: {
105+
baseScore?: number;
106+
timeBonus?: number;
107+
streakBonus?: number;
108+
hintPenalty?: number;
109+
firstSolveBonus?: number;
110+
achievements?: string[];
111+
totalExperience?: number;
112+
};
113+
114+
// ──────────────── Fraud / Anti-Cheat ────────────────
115+
116+
/**
117+
* Fraud/anti-cheat flags recorded during processing.
118+
* Submission may still be stored when fraud is detected for audit purposes.
119+
*/
120+
@Column({ type: 'jsonb', default: {} })
121+
fraudFlags: {
122+
tooFast?: boolean;
123+
minExpectedSeconds?: number;
124+
actualSeconds?: number;
125+
violationTypes?: string[];
126+
riskScore?: number;
127+
};
128+
129+
// ──────────────── Request Metadata ────────────────
130+
131+
@Column({ type: 'varchar', length: 45, nullable: true })
132+
ipAddress?: string;
133+
134+
/**
135+
* Extensible metadata (user-agent, device fingerprint, geo, etc.)
136+
*/
137+
@Column({ type: 'jsonb', default: {} })
138+
metadata: Record<string, any>;
139+
140+
@CreateDateColumn()
141+
@Index()
142+
createdAt: Date;
143+
}

0 commit comments

Comments
 (0)