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+ }
0 commit comments