Skip to content

Commit a49438d

Browse files
authored
Merge pull request #236 from mijinummi/feature/advanced-puzzle-search
feat(puzzles): implement advanced search and filtering API
2 parents e1d186e + d1f7c9b commit a49438d

3 files changed

Lines changed: 322 additions & 23 deletions

File tree

src/puzzle/puzzle.controller.ts

Lines changed: 84 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,112 @@
1-
import { Controller, Post, Body } from '@nestjs/common';
1+
import {
2+
Controller,
3+
Post,
4+
Get,
5+
Body,
6+
Query,
7+
Req,
8+
UseGuards,
9+
ValidationPipe,
10+
} from '@nestjs/common';
11+
import { Request } from 'express';
12+
213
import { PuzzleService } from './puzzle.service';
14+
import { PuzzlesService } from './puzzles.service';
315
import { RewardsService } from '../rewards/rewards.service';
416
import { NFTService } from '../nft/nft.service';
517

6-
@Controller('puzzle')
18+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
19+
import { PuzzleSearchParams } from './puzzles.repository';
20+
21+
interface AuthRequest extends Request {
22+
user: {
23+
id: string;
24+
};
25+
}
26+
27+
@Controller('puzzles')
28+
@UseGuards(JwtAuthGuard)
729
export class PuzzleController {
830
constructor(
931
private readonly puzzleService: PuzzleService,
32+
private readonly puzzlesService: PuzzlesService,
1033
private readonly rewardsService: RewardsService,
1134
private readonly nftService: NFTService,
1235
) {}
1336

37+
/**
38+
* CREATE PUZZLE
39+
*/
1440
@Post('create')
15-
async create(@Body() body: { puzzleId: number; solution: string }) {
41+
async create(
42+
@Body()
43+
body: {
44+
puzzleId: number;
45+
solution: string;
46+
},
47+
) {
1648
return this.puzzleService.createPuzzle(body.puzzleId, body.solution);
1749
}
1850

51+
/**
52+
* VERIFY SOLUTION + REWARDS + NFT MINT
53+
*/
1954
@Post('verify')
20-
async verify(@Body() body: { puzzleId: number; solution: string; userAddress: string }) {
55+
async verify(
56+
@Body()
57+
body: {
58+
puzzleId: number;
59+
solution: string;
60+
userAddress: string;
61+
},
62+
) {
2163
const verification = await this.puzzleService.verifySolution(
2264
body.puzzleId,
2365
body.solution,
2466
);
2567

26-
if (verification.verified) {
27-
// Mark puzzle as completed
28-
await this.puzzleService.markCompleted(body.puzzleId, body.userAddress);
68+
if (!verification.verified) {
69+
return verification;
70+
}
2971

30-
// Distribute token rewards
31-
const rewardResult = await this.rewardsService.distributeReward(body.userAddress, 100);
72+
// mark completion
73+
await this.puzzleService.markCompleted(
74+
body.puzzleId,
75+
body.userAddress,
76+
);
3277

33-
// Mint NFT as achievement
34-
const nftResult = await this.nftService.mintNFT(
78+
// rewards distribution
79+
const rewardResult =
80+
await this.rewardsService.distributeReward(
3581
body.userAddress,
36-
Date.now(),
37-
`ipfs://puzzle-${body.puzzleId}-achievement`,
82+
100,
3883
);
3984

40-
return {
41-
...verification,
42-
rewardDistributed: rewardResult.success,
43-
rewardTransactionHash: rewardResult.transactionHash,
44-
nftMinted: nftResult.success,
45-
nftTransactionHash: nftResult.transactionHash,
46-
};
47-
}
85+
// NFT minting
86+
const nftResult = await this.nftService.mintNFT(
87+
body.userAddress,
88+
Date.now(),
89+
`ipfs://puzzle-${body.puzzleId}-achievement`,
90+
);
4891

49-
return verification;
92+
return {
93+
...verification,
94+
rewardDistributed: rewardResult.success,
95+
rewardTransactionHash: rewardResult.transactionHash,
96+
nftMinted: nftResult.success,
97+
nftTransactionHash: nftResult.transactionHash,
98+
};
5099
}
51-
}
100+
101+
/**
102+
* SEARCH PUZZLES (from repository-based system)
103+
*/
104+
@Get('search')
105+
async search(
106+
@Query(new ValidationPipe({ transform: true }))
107+
query: PuzzleSearchParams,
108+
@Req() req: AuthRequest,
109+
) {
110+
return this.puzzlesService.search(query, req.user.id);
111+
}
112+
}

src/puzzle/puzzles.repository.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { PuzzleEntity } from './entities/puzzle.entity';
5+
6+
export interface PuzzleSearchParams {
7+
q?: string;
8+
type?: string;
9+
difficulty_min?: number | string;
10+
difficulty_max?: number | string;
11+
tags?: string;
12+
author?: string;
13+
status?: 'completed' | 'in-progress' | 'not-started';
14+
sortBy?: string;
15+
order?: 'ASC' | 'DESC';
16+
page?: number | string;
17+
limit?: number | string;
18+
}
19+
20+
@Injectable()
21+
export class PuzzlesRepository {
22+
constructor(
23+
@InjectRepository(PuzzleEntity)
24+
private readonly repo: Repository<PuzzleEntity>,
25+
) {}
26+
27+
async search(params: PuzzleSearchParams, userId: string) {
28+
const qb = this.repo.createQueryBuilder('puzzle');
29+
30+
/**
31+
* FULL TEXT SEARCH (Postgres)
32+
* NOTE: ensure GIN index exists on tsvector expression for performance
33+
*/
34+
if (params.q?.trim()) {
35+
qb.andWhere(
36+
`to_tsvector('english', puzzle.title || ' ' || puzzle.description)
37+
@@ plainto_tsquery(:q)`,
38+
{ q: params.q.trim() },
39+
);
40+
}
41+
42+
// Type filter
43+
if (params.type) {
44+
qb.andWhere('puzzle.type = :type', { type: params.type });
45+
}
46+
47+
// Difficulty range (safe numeric coercion)
48+
if (params.difficulty_min !== undefined) {
49+
qb.andWhere('puzzle.difficulty >= :min', {
50+
min: Number(params.difficulty_min),
51+
});
52+
}
53+
54+
if (params.difficulty_max !== undefined) {
55+
qb.andWhere('puzzle.difficulty <= :max', {
56+
max: Number(params.difficulty_max),
57+
});
58+
}
59+
60+
// Tags (AND logic)
61+
if (params.tags?.trim()) {
62+
const tags = params.tags
63+
.split(',')
64+
.map((t) => t.trim())
65+
.filter(Boolean);
66+
67+
tags.forEach((tag, idx) => {
68+
qb.andWhere(`:tag${idx} = ANY(puzzle.tags)`, {
69+
[`tag${idx}`]: tag,
70+
});
71+
});
72+
}
73+
74+
// Author filter
75+
if (params.author) {
76+
qb.andWhere('puzzle.authorId = :author', {
77+
author: params.author,
78+
});
79+
}
80+
81+
/**
82+
* USER PROGRESS JOIN (only when needed)
83+
*/
84+
if (params.status) {
85+
qb.leftJoin(
86+
'puzzle.progress',
87+
'progress',
88+
'progress.userId = :userId',
89+
{ userId },
90+
);
91+
92+
if (params.status === 'completed') {
93+
qb.andWhere('progress.completed = true');
94+
}
95+
96+
if (params.status === 'in-progress') {
97+
qb.andWhere('progress.started = true AND progress.completed = false');
98+
}
99+
100+
if (params.status === 'not-started') {
101+
qb.andWhere('progress.id IS NULL');
102+
}
103+
}
104+
105+
/**
106+
* SORTING (whitelist recommended to prevent SQL injection)
107+
*/
108+
const allowedSortFields = new Set([
109+
'createdAt',
110+
'difficulty',
111+
'title',
112+
]);
113+
114+
const sortBy = allowedSortFields.has(params.sortBy ?? '')
115+
? params.sortBy!
116+
: 'createdAt';
117+
118+
const order =
119+
params.order?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
120+
121+
qb.orderBy(`puzzle.${sortBy}`, order);
122+
123+
/**
124+
* PAGINATION (safe defaults)
125+
*/
126+
const page = Math.max(parseInt(String(params.page || '1'), 10), 1);
127+
const limit = Math.min(
128+
Math.max(parseInt(String(params.limit || '20'), 10), 1),
129+
100,
130+
);
131+
132+
qb.skip((page - 1) * limit).take(limit);
133+
134+
const [data, total] = await qb.getManyAndCount();
135+
136+
return {
137+
total,
138+
page,
139+
limit,
140+
data,
141+
};
142+
}
143+
}

src/puzzle/puzzles.service.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { PuzzlesService } from './puzzles.service';
3+
import { PuzzlesRepository } from './puzzles.repository';
4+
5+
describe('PuzzlesService.search', () => {
6+
let service: PuzzlesService;
7+
8+
const repoMock = {
9+
search: jest.fn(),
10+
};
11+
12+
beforeEach(async () => {
13+
const module: TestingModule = await Test.createTestingModule({
14+
providers: [
15+
PuzzlesService,
16+
{
17+
provide: PuzzlesRepository,
18+
useValue: repoMock,
19+
},
20+
],
21+
}).compile();
22+
23+
service = module.get<PuzzlesService>(PuzzlesService);
24+
});
25+
26+
afterEach(() => {
27+
jest.clearAllMocks();
28+
});
29+
30+
it('should perform full-text search', async () => {
31+
repoMock.search.mockResolvedValue({
32+
data: [{ id: 1, difficulty: 3, tags: ['logic', 'math'] }],
33+
page: 1,
34+
limit: 20,
35+
total: 1,
36+
});
37+
38+
const result = await service.search({ q: 'logic puzzle' }, 'user1');
39+
40+
expect(result.data.length).toBeGreaterThan(0);
41+
});
42+
43+
it('should filter by difficulty range', async () => {
44+
repoMock.search.mockResolvedValue({
45+
data: [{ difficulty: 3, tags: [] }],
46+
page: 1,
47+
limit: 20,
48+
total: 1,
49+
});
50+
51+
const result = await service.search(
52+
{ difficulty_min: 2, difficulty_max: 4 },
53+
'user1',
54+
);
55+
56+
result.data.forEach((p) => {
57+
expect(p.difficulty).toBeGreaterThanOrEqual(2);
58+
expect(p.difficulty).toBeLessThanOrEqual(4);
59+
});
60+
});
61+
62+
it('should filter by tags AND logic', async () => {
63+
repoMock.search.mockResolvedValue({
64+
data: [{ tags: ['math', 'algebra'] }],
65+
page: 1,
66+
limit: 20,
67+
total: 1,
68+
});
69+
70+
const result = await service.search(
71+
{ tags: 'math,algebra' },
72+
'user1',
73+
);
74+
75+
result.data.forEach((p) => {
76+
expect(p.tags).toEqual(
77+
expect.arrayContaining(['math', 'algebra']),
78+
);
79+
});
80+
});
81+
82+
it('should paginate results', async () => {
83+
repoMock.search.mockResolvedValue({
84+
data: [],
85+
page: 2,
86+
limit: 10,
87+
total: 100,
88+
});
89+
90+
const result = await service.search({ page: 2, limit: 10 }, 'user1');
91+
92+
expect(result.page).toBe(2);
93+
expect(result.limit).toBe(10);
94+
});
95+
});

0 commit comments

Comments
 (0)