@@ -9,51 +9,12 @@ import { logActivity } from "@/lib/activity";
99
1010const MAX_GIG_PAGE = 100_000 ;
1111const MAX_GIG_LIMIT = 50 ;
12- const GIG_STATUSES = [ "active" , "paused" , "closed" ] as const ;
13- type GigStatus = ( typeof GIG_STATUSES ) [ number ] ;
1412
1513// GET /api/gigs - List gigs (public)
1614export async function GET ( request : NextRequest ) {
1715 try {
1816 const searchParams = request . nextUrl . searchParams ;
1917
20- // Validate raw query params that gigFiltersSchema doesn't cover
21- const rawStatus = searchParams . get ( "status" ) ;
22- const statusParam : GigStatus =
23- rawStatus === null || rawStatus === ""
24- ? "active"
25- : ( GIG_STATUSES as readonly string [ ] ) . includes ( rawStatus )
26- ? ( rawStatus as GigStatus )
27- : null ;
28- if ( statusParam === null ) {
29- return NextResponse . json (
30- { error : `Invalid status. Must be one of: ${ GIG_STATUSES . join ( ", " ) } ` } ,
31- { status : 400 }
32- ) ;
33- }
34-
35- // Validate page/limit are positive integers (not 0, not negative, not "abc")
36- const pageRaw = searchParams . get ( "page" ) ;
37- if ( pageRaw !== null ) {
38- const v = Number ( pageRaw ) ;
39- if ( ! Number . isFinite ( v ) || ! Number . isInteger ( v ) || v < 1 ) {
40- return NextResponse . json (
41- { error : "Invalid page. Must be a positive integer (>= 1)." } ,
42- { status : 400 }
43- ) ;
44- }
45- }
46- const limitRaw = searchParams . get ( "limit" ) ;
47- if ( limitRaw !== null ) {
48- const v = Number ( limitRaw ) ;
49- if ( ! Number . isFinite ( v ) || ! Number . isInteger ( v ) || v < 1 ) {
50- return NextResponse . json (
51- { error : "Invalid limit. Must be a positive integer (>= 1)." } ,
52- { status : 400 }
53- ) ;
54- }
55- }
56-
5718 // Parse filters
5819 const filters = gigFiltersSchema . safeParse ( {
5920 search : ( searchParams . get ( "search" ) || "" ) . slice ( 0 , 200 ) || undefined ,
@@ -66,13 +27,13 @@ export async function GET(request: NextRequest) {
6627 account_type : searchParams . get ( "account_type" ) || undefined ,
6728 listing_type : searchParams . get ( "listing_type" ) || undefined ,
6829 sort : searchParams . get ( "sort" ) || "newest" ,
69- page : pageRaw ? Math . min ( Number ( pageRaw ) , MAX_GIG_PAGE ) : 1 ,
70- limit : limitRaw ? Math . min ( Number ( limitRaw ) , MAX_GIG_LIMIT ) : 20 ,
30+ page : Math . min ( Number ( searchParams . get ( "page" ) ) || 1 , MAX_GIG_PAGE ) ,
31+ limit : Math . min ( Number ( searchParams . get ( "limit" ) ) || 20 , MAX_GIG_LIMIT ) ,
7132 } ) ;
7233
7334 if ( ! filters . success ) {
7435 return NextResponse . json (
75- { error : filters . error . issues [ 0 ] . message , issues : filters . error . issues } ,
36+ { error : filters . error . issues [ 0 ] . message } ,
7637 { status : 400 }
7738 ) ;
7839 }
@@ -100,7 +61,7 @@ export async function GET(request: NextRequest) {
10061 ` ,
10162 { count : "exact" }
10263 )
103- . eq ( "status" , statusParam ) ;
64+ . eq ( "status" , "active" ) ;
10465
10566 // Apply filters — use textSearch or individual filters to prevent PostgREST filter injection (#71)
10667 if ( search ) {
@@ -115,3 +76,220 @@ export async function GET(request: NextRequest) {
11576 . replace ( / \. / g, "\\." ) ;
11677 query = query . or ( `title.ilike.%${ safeSearch } %,description.ilike.%${ safeSearch } %` ) ;
11778 }
79+
80+ if ( category ) {
81+ query = query . eq ( "category" , category ) ;
82+ }
83+
84+ if ( skills && skills . length > 0 ) {
85+ query = query . overlaps ( "skills_required" , skills ) ;
86+ }
87+
88+ if ( budget_type ) {
89+ query = query . eq ( "budget_type" , budget_type ) ;
90+ }
91+
92+ if ( budget_min !== undefined ) {
93+ query = query . gte ( "budget_max" , budget_min ) ;
94+ }
95+
96+ if ( budget_max !== undefined ) {
97+ query = query . lte ( "budget_min" , budget_max ) ;
98+ }
99+
100+ if ( location_type ) {
101+ query = query . eq ( "location_type" , location_type ) ;
102+ }
103+
104+ // Default to 'hiring' unless explicitly requesting for_hire or 'all'
105+ if ( listing_type === "all" ) {
106+ // No filter — return both types
107+ } else if ( listing_type ) {
108+ query = query . eq ( "listing_type" , listing_type ) ;
109+ } else {
110+ query = query . eq ( "listing_type" , "hiring" ) ;
111+ }
112+
113+ if ( account_type ) {
114+ query = query . eq ( "poster:profiles!poster_id.account_type" , account_type ) ;
115+ }
116+
117+ // Apply sorting
118+ switch ( sort ) {
119+ case "oldest" :
120+ query = query . order ( "created_at" , { ascending : true } ) ;
121+ break ;
122+ case "budget_high" :
123+ query = query . order ( "budget_max" , { ascending : false , nullsFirst : false } ) ;
124+ break ;
125+ case "budget_low" :
126+ query = query . order ( "budget_min" , { ascending : true , nullsFirst : false } ) ;
127+ break ;
128+ default : // newest
129+ query = query . order ( "created_at" , { ascending : false } ) ;
130+ }
131+
132+ // Apply pagination — ensure non-negative offset (#69)
133+ const offset = Math . max ( 0 , ( page - 1 ) * limit ) ;
134+ query = query . range ( offset , offset + limit - 1 ) ;
135+
136+ const { data : gigs , error, count } = await query ;
137+
138+ if ( error ) {
139+ return NextResponse . json ( { error : error . message } , { status : 400 } ) ;
140+ }
141+
142+ return NextResponse . json ( {
143+ gigs,
144+ pagination : {
145+ page,
146+ limit,
147+ total : count || 0 ,
148+ totalPages : Math . ceil ( ( count || 0 ) / limit ) ,
149+ } ,
150+ } ) ;
151+ } catch {
152+ return NextResponse . json (
153+ { error : "An unexpected error occurred" } ,
154+ { status : 500 }
155+ ) ;
156+ }
157+ }
158+
159+ // POST /api/gigs - Create a gig
160+ export async function POST ( request : NextRequest ) {
161+ try {
162+ const auth = await getAuthContext ( request ) ;
163+ if ( ! auth ) {
164+ return NextResponse . json ( { error : "Unauthorized" } , { status : 401 } ) ;
165+ }
166+ const { user, supabase } = auth ;
167+
168+ const rl = checkRateLimit ( getRateLimitIdentifier ( request , user . id ) , "write" ) ;
169+ if ( ! rl . allowed ) return rateLimitExceeded ( rl ) ;
170+
171+ let body : unknown ;
172+ try {
173+ const text = await request . text ( ) ;
174+ if ( ! text || text . trim ( ) . length === 0 ) {
175+ return NextResponse . json ( { error : "Request body is required" } , { status : 400 } ) ;
176+ }
177+ body = stripProtoPollution ( JSON . parse ( text ) ) ;
178+ } catch {
179+ return NextResponse . json ( { error : "Invalid JSON body" } , { status : 400 } ) ;
180+ }
181+
182+ // Sanitize inputs (#47, #52)
183+ if ( typeof ( body as any ) . title === "string" ) {
184+ ( body as any ) . title = sanitizeTitle ( ( body as any ) . title ) ;
185+ }
186+ if ( typeof ( body as any ) . description === "string" ) {
187+ ( body as any ) . description = sanitizeContent ( ( body as any ) . description ) ;
188+ }
189+
190+ // Default ai_tools_preferred to empty array if not provided (#45)
191+ if ( ! ( body as any ) . ai_tools_preferred ) {
192+ ( body as any ) . ai_tools_preferred = [ ] ;
193+ }
194+
195+ const validationResult = gigSchema . safeParse ( body ) ;
196+
197+ if ( ! validationResult . success ) {
198+ return NextResponse . json (
199+ { error : validationResult . error . issues [ 0 ] . message } ,
200+ { status : 400 }
201+ ) ;
202+ }
203+
204+ const isActivePost = validationResult . data . status === "active" ;
205+ const now = new Date ( ) ;
206+ const month = now . getMonth ( ) + 1 ;
207+ const year = now . getFullYear ( ) ;
208+
209+ // Only check gig limit for active posts
210+ if ( isActivePost ) {
211+ const { data : subscription } = await supabase
212+ . from ( "subscriptions" )
213+ . select ( "plan" )
214+ . eq ( "user_id" , user . id )
215+ . single ( ) ;
216+
217+ if ( ! subscription || subscription . plan === "free" ) {
218+ const { data : usage } = await supabase
219+ . from ( "gig_usage" )
220+ . select ( "posts_count" )
221+ . eq ( "user_id" , user . id )
222+ . eq ( "month" , month )
223+ . eq ( "year" , year )
224+ . single ( ) ;
225+
226+ if ( usage && usage . posts_count >= 10 ) {
227+ return NextResponse . json (
228+ {
229+ error :
230+ "You've reached your monthly limit of 10 gig posts. Upgrade to Pro for unlimited posts." ,
231+ } ,
232+ { status : 403 }
233+ ) ;
234+ }
235+ }
236+ }
237+
238+ // Create the gig
239+ const { data : gig , error } = await supabase
240+ . from ( "gigs" )
241+ . insert ( {
242+ poster_id : user . id ,
243+ listing_type : validationResult . data . listing_type || "hiring" ,
244+ ...validationResult . data ,
245+ } )
246+ . select ( )
247+ . single ( ) ;
248+
249+ if ( error ) {
250+ return NextResponse . json ( { error : error . message } , { status : 400 } ) ;
251+ }
252+
253+ // Only update usage count for active posts
254+ if ( isActivePost ) {
255+ await supabase . from ( "gig_usage" ) . upsert (
256+ {
257+ user_id : user . id ,
258+ month,
259+ year,
260+ posts_count : 1 ,
261+ } ,
262+ {
263+ onConflict : "user_id,month,year" ,
264+ }
265+ ) ;
266+
267+ await supabase . rpc ( "increment_gig_usage" , {
268+ p_user_id : user . id ,
269+ p_month : month ,
270+ p_year : year ,
271+ } ) ;
272+ }
273+
274+ // Fire reputation receipt
275+ getUserDid ( supabase , user . id ) . then ( ( did ) => {
276+ if ( did ) onGigPosted ( did , gig . id ) ;
277+ } ) . catch ( ( ) => { } ) ;
278+
279+ // Log activity
280+ void logActivity ( supabase , {
281+ userId : user . id ,
282+ activityType : "gig_posted" ,
283+ referenceId : gig . id ,
284+ referenceType : "gig" ,
285+ metadata : { gig_title : gig . title } ,
286+ } ) ;
287+
288+ return NextResponse . json ( { gig } , { status : 201 } ) ;
289+ } catch {
290+ return NextResponse . json (
291+ { error : "An unexpected error occurred" } ,
292+ { status : 500 }
293+ ) ;
294+ }
295+ }
0 commit comments