@@ -2,25 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
22import { supabase } from "@/lib/supabase" ;
33import { checkRateLimit , getClientIp } from "@/lib/rate-limit" ;
44
5- type SearchScope = "component" | "profile" ;
6- type ComponentType = "skill" | "agent" | "hook" | "script" | "knowledge" | "rules" ;
5+ const VALID_SCOPES = new Set ( [ "component" , "profile" ] ) ;
6+ const VALID_COMPONENT_TYPES = new Set ( [
7+ "skill" , "agent" , "hook" , "script" , "knowledge" , "rules" , "plugin" ,
8+ ] ) ;
79
810export async function GET ( request : NextRequest ) {
9- // Start performance monitoring
1011 const startTime = performance . now ( ) ;
1112
1213 const { searchParams } = request . nextUrl ;
1314 const query = searchParams . get ( "q" ) ?? "" ;
14- const scope = ( searchParams . get ( "scope" ) as SearchScope ) ?? "component" ;
15-
16- // Pagination parameters
17- const page = parseInt ( searchParams . get ( "page" ) ?? "1" , 10 ) ;
18- const limit = Math . min ( parseInt ( searchParams . get ( "limit" ) ?? "20" , 10 ) , 100 ) ; // Max 100 per page
19-
20- // Filter parameters for components
21- const componentType = searchParams . get ( "type" ) as ComponentType | null ;
22- const category = searchParams . get ( "category" ) ;
23- const minRating = searchParams . get ( "rating" ) ;
2415
2516 if ( ! query ) {
2617 return NextResponse . json (
@@ -29,13 +20,59 @@ export async function GET(request: NextRequest) {
2920 ) ;
3021 }
3122
23+ if ( query . length > 200 ) {
24+ return NextResponse . json (
25+ { error : "Query too long (max 200 characters)" } ,
26+ { status : 400 } ,
27+ ) ;
28+ }
29+
30+ const scopeParam = searchParams . get ( "scope" ) ?? "component" ;
31+ if ( ! VALID_SCOPES . has ( scopeParam ) ) {
32+ return NextResponse . json (
33+ { error : "Invalid scope. Must be 'component' or 'profile'" } ,
34+ { status : 400 } ,
35+ ) ;
36+ }
37+ const scope = scopeParam as "component" | "profile" ;
38+
39+ // Pagination parameters
40+ const page = parseInt ( searchParams . get ( "page" ) ?? "1" , 10 ) ;
41+ const limit = Math . min ( parseInt ( searchParams . get ( "limit" ) ?? "20" , 10 ) , 100 ) ;
42+
3243 if ( page < 1 ) {
3344 return NextResponse . json (
3445 { error : "Page must be >= 1" } ,
3546 { status : 400 } ,
3647 ) ;
3748 }
3849
50+ // Filter parameters — validated before use
51+ const componentTypeParam = searchParams . get ( "type" ) ;
52+ if ( componentTypeParam !== null && ! VALID_COMPONENT_TYPES . has ( componentTypeParam ) ) {
53+ return NextResponse . json (
54+ { error : `Invalid type filter. Must be one of: ${ [ ...VALID_COMPONENT_TYPES ] . join ( ", " ) } ` } ,
55+ { status : 400 } ,
56+ ) ;
57+ }
58+ const componentType = componentTypeParam as string | null ;
59+
60+ const category = searchParams . get ( "category" ) ;
61+
62+ // Parse and validate rating at the boundary
63+ let minRating : number | null = null ;
64+ const ratingParam = searchParams . get ( "rating" ) ;
65+ if ( ratingParam !== null ) {
66+ const parsed = parseFloat ( ratingParam ) ;
67+ if ( isNaN ( parsed ) || parsed < 1 || parsed > 5 ) {
68+ return NextResponse . json (
69+ { error : "Rating must be a number between 1 and 5" } ,
70+ { status : 400 } ,
71+ ) ;
72+ }
73+ minRating = parsed ;
74+ }
75+
3976 // 60 searches per minute per IP
4077 const ip = getClientIp ( request ) ;
4178 const { allowed, retryAfter } = checkRateLimit ( `search:${ ip } ` , {
@@ -72,7 +109,7 @@ export async function GET(request: NextRequest) {
72109 . select ( "*" )
73110 . textSearch ( "fts" , tsquery )
74111 . order ( "name" , { ascending : true } )
75- . range ( offset , offset + limit - 1 ) ;
112+ . range ( offset , offset + limit ) ; // fetch limit+1 to detect hasMore
76113
77114 if ( error ) {
78115 // Fallback to ilike if FTS fails — use parameterized filters
@@ -81,99 +118,62 @@ export async function GET(request: NextRequest) {
81118 . select ( "*" )
82119 . or ( `name.ilike.%${ query . replace ( / [ , ( ) . % _ \\ ] / g, "" ) } %,description.ilike.%${ query . replace ( / [ , ( ) . % _ \\ ] / g, "" ) } %` )
83120 . order ( "name" , { ascending : true } )
84- . range ( offset , offset + limit - 1 ) ;
121+ . range ( offset , offset + limit ) ; // fetch limit+1 to detect hasMore
85122
86123 if ( fallbackError ) {
87- return NextResponse . json (
88- { error : `Search failed: ${ fallbackError . message } ` } ,
89- { status : 500 } ,
90- ) ;
124+ console . error ( "[search] profile fallback failed" , fallbackError ) ;
125+ return NextResponse . json ( { error : "Search failed" } , { status : 500 } ) ;
91126 }
92127
93- const endTime = performance . now ( ) ;
94- const responseTime = ( ( endTime - startTime ) / 1000 ) . toFixed ( 3 ) ;
95-
96- return NextResponse . json (
97- {
98- type : "profile" ,
99- results : fallback ?? [ ] ,
100- pagination : {
101- page,
102- limit,
103- hasMore : ( fallback ?. length ?? 0 ) === limit ,
104- } ,
105- } ,
106- {
107- headers : {
108- "X-Response-Time" : `${ responseTime } s` ,
109- } ,
110- }
111- ) ;
128+ const results = fallback ?? [ ] ;
129+ return jsonResponse ( startTime , {
130+ type : "profile" ,
131+ results : results . slice ( 0 , limit ) ,
132+ pagination : { page, limit, hasMore : results . length > limit } ,
133+ } ) ;
112134 }
113135
114- const endTime = performance . now ( ) ;
115- const responseTime = ( ( endTime - startTime ) / 1000 ) . toFixed ( 3 ) ;
116-
117- return NextResponse . json (
118- {
119- type : "profile" ,
120- results : data ?? [ ] ,
121- pagination : {
122- page,
123- limit,
124- hasMore : ( data ?. length ?? 0 ) === limit ,
125- } ,
126- } ,
127- {
128- headers : {
129- "X-Response-Time" : `${ responseTime } s` ,
130- } ,
131- }
132- ) ;
136+ const results = data ?? [ ] ;
137+ return jsonResponse ( startTime , {
138+ type : "profile" ,
139+ results : results . slice ( 0 , limit ) ,
140+ pagination : { page, limit, hasMore : results . length > limit } ,
141+ } ) ;
133142 }
134143
135144 // Default: search components using full-text search with filters
136145 try {
137146 const results = await searchComponents ( tsquery , query , componentType , category , minRating , page , limit ) ;
138-
139- const endTime = performance . now ( ) ;
140- const responseTime = ( ( endTime - startTime ) / 1000 ) . toFixed ( 3 ) ;
141-
142- return NextResponse . json (
143- {
144- type : "component" ,
145- results : results . data ,
146- pagination : {
147- page,
148- limit,
149- hasMore : results . hasMore ,
150- } ,
151- } ,
152- {
153- headers : {
154- "X-Response-Time" : `${ responseTime } s` ,
155- } ,
156- }
157- ) ;
158- } catch ( error : any ) {
159- return NextResponse . json (
160- { error : `Search failed: ${ error . message } ` } ,
161- { status : 500 } ,
162- ) ;
147+ return jsonResponse ( startTime , {
148+ type : "component" ,
149+ results : results . data ,
150+ pagination : { page, limit, hasMore : results . hasMore } ,
151+ } ) ;
152+ } catch ( err ) {
153+ console . error ( "[search] component search failed" , err ) ;
154+ return NextResponse . json ( { error : "Search failed" } , { status : 500 } ) ;
163155 }
164156}
165157
158+ function jsonResponse ( startTime : number , body : object ) {
159+ const responseTime = ( ( performance . now ( ) - startTime ) / 1000 ) . toFixed ( 3 ) ;
160+ return NextResponse . json ( body , {
161+ headers : { "X-Response-Time" : `${ responseTime } s` } ,
162+ } ) ;
163+ }
164+
166165async function searchComponents (
167166 tsquery : string ,
168167 rawQuery : string ,
169- componentType : ComponentType | null ,
168+ componentType : string | null ,
170169 category : string | null ,
171- minRating : string | null ,
170+ minRating : number | null ,
172171 page : number ,
173172 limit : number ,
174173) : Promise < { data : any [ ] ; hasMore : boolean } > {
175174 const offset = ( page - 1 ) * limit ;
176- // Step 1: Get component IDs that match the category filter (if specified)
175+
176+ // Step 1: Resolve category filter to component IDs (if specified)
177177 let categoryComponentIds : string [ ] | null = null ;
178178 if ( category ) {
179179 const { data : categoryData , error : categoryError } = await supabase
@@ -184,31 +184,27 @@ async function searchComponents(
184184 if ( categoryError ) throw categoryError ;
185185 categoryComponentIds = categoryData ?. map ( ( cc : any ) => cc . component_id ) ?? [ ] ;
186186
187- // If category filter is specified but no components match, return empty
188187 if ( categoryComponentIds . length === 0 ) {
189188 return { data : [ ] , hasMore : false } ;
190189 }
191190 }
192191
193- // Step 2: Build the main component search query with FTS
192+ // Step 2: Build and execute the FTS query
194193 let componentQuery = supabase
195194 . from ( "components" )
196195 . select ( "*" )
197196 . textSearch ( "fts" , tsquery ) ;
198197
199- // Apply component type filter
200198 if ( componentType ) {
201199 componentQuery = componentQuery . eq ( "type" , componentType ) ;
202200 }
203201
204- // Apply category filter (if we have matching component IDs)
205202 if ( categoryComponentIds ) {
206203 componentQuery = componentQuery . in ( "id" , categoryComponentIds ) ;
207204 }
208205
209- // Execute the query - fetch extra to determine if there are more pages
210- // When rating filter is present, we need more data for post-filtering
211- const fetchLimit = minRating ? Math . max ( limit * 3 , 100 ) : limit + 1 ;
206+ // When a rating filter is active, over-fetch to compensate for post-filter attrition
207+ const fetchLimit = minRating !== null ? Math . max ( limit * 3 , 100 ) : limit + 1 ;
212208
213209 const { data : components , error } = await componentQuery
214210 . order ( "install_count" , { ascending : false } )
@@ -235,82 +231,54 @@ async function searchComponents(
235231
236232 if ( fallbackError ) throw fallbackError ;
237233
238- // Apply rating filter if specified
239- if ( minRating && fallback ) {
240- const minRatingNum = parseFloat ( minRating ) ;
241- const filteredResults = await filterByRating ( fallback , minRatingNum ) ;
242- const hasMore = filteredResults . length > limit ;
243- return {
244- data : filteredResults . slice ( 0 , limit ) ,
245- hasMore,
246- } ;
247- }
248-
249- const resultData = fallback ?? [ ] ;
250- const hasMore = resultData . length > limit ;
251- return {
252- data : resultData . slice ( 0 , limit ) ,
253- hasMore,
254- } ;
234+ return applyRatingFilter ( fallback ?? [ ] , minRating , limit ) ;
255235 }
256236
257- // Step 3: Apply rating filter if specified
258- if ( minRating && components ) {
259- const minRatingNum = parseFloat ( minRating ) ;
260- const filteredResults = await filterByRating ( components , minRatingNum ) ;
261- const hasMore = filteredResults . length > limit ;
262- return {
263- data : filteredResults . slice ( 0 , limit ) ,
264- hasMore,
265- } ;
237+ return applyRatingFilter ( components ?? [ ] , minRating , limit ) ;
238+ }
239+
240+ async function applyRatingFilter (
241+ components : any [ ] ,
242+ minRating : number | null ,
243+ limit : number ,
244+ ) : Promise < { data : any [ ] ; hasMore : boolean } > {
245+ if ( minRating === null ) {
246+ const hasMore = components . length > limit ;
247+ return { data : components . slice ( 0 , limit ) , hasMore } ;
266248 }
267249
268- const resultData = components ?? [ ] ;
269- const hasMore = resultData . length > limit ;
270- return {
271- data : resultData . slice ( 0 , limit ) ,
272- hasMore,
273- } ;
250+ const filtered = await filterByRating ( components , minRating ) ;
251+ const hasMore = filtered . length > limit ;
252+ return { data : filtered . slice ( 0 , limit ) , hasMore } ;
274253}
275254
276- // Helper function to filter components by minimum average rating
277- async function filterByRating ( components : any [ ] , minRating : number ) {
255+ async function filterByRating ( components : any [ ] , minRating : number ) : Promise < any [ ] > {
278256 if ( components . length === 0 ) return [ ] ;
279257
280258 const componentIds = components . map ( c => c . id ) ;
281259
282- // Get average ratings for all components
283260 const { data : ratings } = await supabase
284261 . from ( "ratings" )
285262 . select ( "component_id, rating" )
286263 . in ( "component_id" , componentIds ) ;
287264
288265 if ( ! ratings || ratings . length === 0 ) {
289- // No ratings = only return if minRating is 0
290- return minRating === 0 ? components : [ ] ;
266+ return [ ] ;
291267 }
292268
293- // Calculate average rating per component
294- const ratingMap = new Map < string , number > ( ) ;
295- const countMap = new Map < string , number > ( ) ;
269+ // Compute average rating per component
270+ const ratingSum = new Map < string , number > ( ) ;
271+ const ratingCount = new Map < string , number > ( ) ;
296272
297- ratings . forEach ( r => {
298- const current = ratingMap . get ( r . component_id ) || 0 ;
299- const count = countMap . get ( r . component_id ) || 0 ;
300- ratingMap . set ( r . component_id , current + r . rating ) ;
301- countMap . set ( r . component_id , count + 1 ) ;
302- } ) ;
273+ for ( const r of ratings ) {
274+ ratingSum . set ( r . component_id , ( ratingSum . get ( r . component_id ) ?? 0 ) + r . rating ) ;
275+ ratingCount . set ( r . component_id , ( ratingCount . get ( r . component_id ) ?? 0 ) + 1 ) ;
276+ }
303277
304- // Calculate averages and filter
305278 return components . filter ( component => {
306- const totalRating = ratingMap . get ( component . id ) ;
307- const count = countMap . get ( component . id ) ;
308-
309- if ( ! totalRating || ! count ) {
310- return minRating === 0 ; // No ratings = only include if minRating is 0
311- }
312-
313- const avgRating = totalRating / count ;
314- return avgRating >= minRating ;
279+ const sum = ratingSum . get ( component . id ) ;
280+ const count = ratingCount . get ( component . id ) ;
281+ if ( sum === undefined || count === undefined ) return false ;
282+ return sum / count >= minRating ;
315283 } ) ;
316284}
0 commit comments