Skip to content
60 changes: 49 additions & 11 deletions src/app/api/bounties/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,75 @@ import { createClient } from "@/lib/supabase/server";
import { getAuthContext } from "@/lib/auth/get-user";
import { createBountySchema } from "@/lib/bounties";

// GET /api/bounties — public list of open bounties
const BOUNTY_STATUSES = ["open", "paused", "closed"] as const;
type BountyStatus = (typeof BOUNTY_STATUSES)[number];

// GET /api/bounties — public list of bounties
export async function GET(request: NextRequest) {
try {
const params = request.nextUrl.searchParams;
const status = params.get("status") || "open";
const defaultLimit = 50;
const statusParam = params.get("status") || "open";
if (!(BOUNTY_STATUSES as readonly string[]).includes(statusParam)) {
return NextResponse.json(
{ error: `Invalid status. Must be one of: ${BOUNTY_STATUSES.join(", ")}` },
{ status: 400 }
);
}
const status = statusParam as BountyStatus;

const defaultLimit = 50;
const limitRaw = Number(params.get("limit"));
if (params.get("limit") !== null && (!Number.isFinite(limitRaw) || limitRaw <= 0)) {
return NextResponse.json(
{ error: "Invalid limit. Must be a positive integer." },
{ status: 400 }
);
}
const limitCandidate =
Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : defaultLimit;
const limit = Math.min(limitCandidate, 100);

const pageRaw = Number(params.get("page"));
if (params.get("page") !== null && (!Number.isFinite(pageRaw) || pageRaw <= 0)) {
return NextResponse.json(
{ error: "Invalid page. Must be a positive integer." },
{ status: 400 }
);
}
const page = Number.isFinite(pageRaw) && pageRaw > 0 ? Math.floor(pageRaw) : 1;
const offset = (page - 1) * limit;

const supabase = await createClient();

const { data, error } = await supabase
const { data, error, count } = await supabase
.from("bounties" as any)
.select(
`
id, title, description, payout_usd, payout_currency, max_submissions,
status, closes_at, created_at,
id, title, description, payout_usd, payout_currency, payment_coin,
max_submissions, status, closes_at, questions, created_at, updated_at,
creator:profiles!creator_id (id, username, full_name, avatar_url)
`
`,
{ count: "exact" }
)
.eq("status", status)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);

if (error) {
console.error("[GET /api/bounties] Supabase error:", error);
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ data: data || [] });
} catch {
return NextResponse.json({
data: data || [],
pagination: {
page,
limit,
total: count ?? 0,
total_pages: count ? Math.ceil(count / limit) : 0,
},
});
} catch (err) {
console.error("[GET /api/bounties] Unexpected error:", err);
return NextResponse.json({ error: "Unexpected error" }, { status: 500 });
}
}
Expand All @@ -57,7 +90,10 @@ export async function POST(request: NextRequest) {
const parsed = createBountySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0].message },
{
error: parsed.error.issues[0].message,
issues: parsed.error.issues,
},
Comment on lines +93 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Bounties POST still exposes only the first Zod issue as error

The reviews POST was updated to use a generic "Invalid review payload" string so the issues array is the authoritative source of failures. The bounties POST was only half-updated — it now includes issues in the response body, but the top-level error field still shows issues[0].message. This makes the error contract inconsistent across the two endpoints and means clients who read only error still miss all but the first validation failure.

Suggested change
{
error: parsed.error.issues[0].message,
issues: parsed.error.issues,
},
{
error: "Invalid bounty payload",
issues: parsed.error.issues,
},

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

{ status: 400 }
);
}
Expand Down Expand Up @@ -85,10 +121,12 @@ export async function POST(request: NextRequest) {
.single();

if (error) {
console.error("[POST /api/bounties] Supabase error:", error);
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ data }, { status: 201 });
} catch {
} catch (err) {
console.error("[POST /api/bounties] Unexpected error:", err);
return NextResponse.json({ error: "Unexpected error" }, { status: 500 });
}
}
50 changes: 46 additions & 4 deletions src/app/api/reviews/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,47 @@ export async function GET(request: NextRequest) {
const supabase = await createClient();
const { searchParams } = new URL(request.url);
const gigId = searchParams.get("gig_id");
const limit = parsePaginationParam(searchParams.get("limit"), 20, 1, 50);
const offset = parsePaginationParam(searchParams.get("offset"), 0, 0, 100_000);

// Validate gig_id as UUID if provided
if (gigId) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(gigId)) {
return NextResponse.json(
{ error: "Invalid gig_id. Must be a valid UUID." },
{ status: 400 }
);
}
}

// Validate limit
const MAX_REVIEW_LIMIT = 50;
const MAX_REVIEW_OFFSET = 100_000;
const limitRaw = searchParams.get("limit");
let limit = 20;
if (limitRaw !== null) {
const v = Number(limitRaw);
if (!Number.isFinite(v) || !Number.isInteger(v) || v < 1) {
return NextResponse.json(
{ error: "Invalid limit. Must be a positive integer (>= 1)." },
{ status: 400 }
);
}
limit = Math.min(v, MAX_REVIEW_LIMIT);
}

// Validate offset
const offsetRaw = searchParams.get("offset");
let offset = 0;
if (offsetRaw !== null) {
const v = Number(offsetRaw);
if (!Number.isFinite(v) || !Number.isInteger(v) || v < 0) {
return NextResponse.json(
{ error: "Invalid offset. Must be a non-negative integer (>= 0)." },
{ status: 400 }
);
}
offset = Math.min(v, MAX_REVIEW_OFFSET);
}

let query = supabase
.from("reviews")
Expand Down Expand Up @@ -58,6 +97,7 @@ export async function GET(request: NextRequest) {
const { data: reviews, error, count } = await query;

if (error) {
console.error("[GET /api/reviews] Supabase error:", error);
return NextResponse.json({ error: error.message }, { status: 400 });
}

Expand All @@ -69,7 +109,8 @@ export async function GET(request: NextRequest) {
offset,
},
});
} catch {
} catch (err) {
console.error("[GET /api/reviews] Unexpected error:", err);
return NextResponse.json(
{ error: "An unexpected error occurred" },
{ status: 500 }
Expand All @@ -91,7 +132,7 @@ export async function POST(request: NextRequest) {

if (!validationResult.success) {
return NextResponse.json(
{ error: validationResult.error.issues[0].message },
{ error: "Invalid review payload", issues: validationResult.error.issues },
{ status: 400 }
);
}
Expand Down Expand Up @@ -203,6 +244,7 @@ export async function POST(request: NextRequest) {
.single();

if (createError) {
console.error("[POST /api/reviews] Supabase error:", createError);
return NextResponse.json({ error: createError.message }, { status: 400 });
}

Expand Down
Loading