Skip to content

Commit ee8fe32

Browse files
chore: restore src/app/api/gigs/route.ts to upstream (was: 4487, now: 8988)
1 parent 19a6ac4 commit ee8fe32

1 file changed

Lines changed: 221 additions & 43 deletions

File tree

src/app/api/gigs/route.ts

Lines changed: 221 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,51 +9,12 @@ import { logActivity } from "@/lib/activity";
99

1010
const MAX_GIG_PAGE = 100_000;
1111
const 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)
1614
export 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

Comments
 (0)