From 27f5b3132efdc30660d4abce660032b5aa2f3f5c Mon Sep 17 00:00:00 2001 From: Jorel97 <83238249+Jorel97@users.noreply.github.com> Date: Fri, 29 May 2026 17:52:41 -0600 Subject: [PATCH] fix(api): clamp activity and review pagination --- src/app/api/activity/route.ts | 5 +- src/app/api/reviews/route.ts | 5 +- .../api/users/[username]/activity/route.ts | 125 ++++++++--------- src/app/api/users/[username]/reviews/route.ts | 130 ++++++++---------- src/lib/api-pagination.test.ts | 18 +++ src/lib/api-pagination.ts | 14 ++ 6 files changed, 152 insertions(+), 145 deletions(-) create mode 100644 src/lib/api-pagination.test.ts create mode 100644 src/lib/api-pagination.ts diff --git a/src/app/api/activity/route.ts b/src/app/api/activity/route.ts index c2faf966..3d31fe18 100644 --- a/src/app/api/activity/route.ts +++ b/src/app/api/activity/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; +import { parsePaginationParam } from "@/lib/api-pagination"; // GET /api/activity - User's own activity feed (includes private activities) // When authenticated, returns the user's own activities including private ones @@ -12,8 +13,8 @@ export async function GET(request: NextRequest) { const { user, supabase } = auth; const searchParams = request.nextUrl.searchParams; - const limit = Math.min(parseInt(searchParams.get("limit") || "20"), 50); - const offset = parseInt(searchParams.get("offset") || "0"); + const limit = parsePaginationParam(searchParams.get("limit"), 20, 1, 50); + const offset = parsePaginationParam(searchParams.get("offset"), 0, 0, 100_000); // Fetch all activities for the authenticated user (including private) const { data: activities, error, count } = await supabase diff --git a/src/app/api/reviews/route.ts b/src/app/api/reviews/route.ts index a14b6fe2..58e7421a 100644 --- a/src/app/api/reviews/route.ts +++ b/src/app/api/reviews/route.ts @@ -6,6 +6,7 @@ import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch"; import { sendEmail, reviewReceivedEmail } from "@/lib/email"; import { getUserDid, onReviewCreated } from "@/lib/reputation-hooks"; import { logActivity } from "@/lib/activity"; +import { parsePaginationParam } from "@/lib/api-pagination"; const createReviewSchema = z.object({ gig_id: z.string().uuid("Invalid gig ID"), @@ -20,8 +21,8 @@ export async function GET(request: NextRequest) { const supabase = await createClient(); const { searchParams } = new URL(request.url); const gigId = searchParams.get("gig_id"); - const limit = Math.min(parseInt(searchParams.get("limit") || "20"), 50); - const offset = parseInt(searchParams.get("offset") || "0"); + const limit = parsePaginationParam(searchParams.get("limit"), 20, 1, 50); + const offset = parsePaginationParam(searchParams.get("offset"), 0, 0, 100_000); let query = supabase .from("reviews") diff --git a/src/app/api/users/[username]/activity/route.ts b/src/app/api/users/[username]/activity/route.ts index 44d675f3..d3814d06 100644 --- a/src/app/api/users/[username]/activity/route.ts +++ b/src/app/api/users/[username]/activity/route.ts @@ -1,79 +1,70 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; - -function parsePositiveInt(value: string | null, fallback: number): number { - const parsed = Number.parseInt(value || "", 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function parseNonNegativeInt(value: string | null, fallback: number): number { - const parsed = Number.parseInt(value || "", 10); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; -} +import { parsePaginationParam } from "@/lib/api-pagination"; // GET /api/users/:username/activity - Public activity feed for a user export async function GET( request: NextRequest, { params }: { params: Promise<{ username: string }> } ) { - try { + try { const { username } = await params; const searchParams = request.nextUrl.searchParams; - const limit = Math.min(parsePositiveInt(searchParams.get("limit"), 20), 50); - const offset = parseNonNegativeInt(searchParams.get("offset"), 0); + const limit = parsePaginationParam(searchParams.get("limit"), 20, 1, 50); + const offset = parsePaginationParam(searchParams.get("offset"), 0, 0, 100_000); const supabase = await createClient(); - - // First look up the user by username - const { data: profile, error: profileError } = await supabase - .from("profiles") - .select("id") - .eq("username", username) - .single(); - - if (profileError || !profile) { - return NextResponse.json( - { error: "User not found" }, - { status: 404 } - ); - } - - // Fetch public activities for this user - const { data: activities, error, count } = await supabase - .from("activities") - .select( - ` - *, - user:profiles!user_id ( - id, - username, - full_name, - avatar_url - ) - `, - { count: "exact" } - ) - .eq("user_id", profile.id) - .eq("is_public", true) - .order("created_at", { ascending: false }) - .range(offset, offset + limit - 1); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - - return NextResponse.json({ - data: activities, - pagination: { - total: count || 0, - limit, - offset, - }, - }); - } catch { - return NextResponse.json( - { error: "An unexpected error occurred" }, - { status: 500 } - ); - } -} + + // First look up the user by username + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("id") + .eq("username", username) + .single(); + + if (profileError || !profile) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + // Fetch public activities for this user + const { data: activities, error, count } = await supabase + .from("activities") + .select( + ` + *, + user:profiles!user_id ( + id, + username, + full_name, + avatar_url + ) + `, + { count: "exact" } + ) + .eq("user_id", profile.id) + .eq("is_public", true) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ + data: activities, + pagination: { + total: count || 0, + limit, + offset, + }, + }); + } catch { + return NextResponse.json( + { error: "An unexpected error occurred" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/users/[username]/reviews/route.ts b/src/app/api/users/[username]/reviews/route.ts index a83435fd..f1dd62a4 100644 --- a/src/app/api/users/[username]/reviews/route.ts +++ b/src/app/api/users/[username]/reviews/route.ts @@ -1,99 +1,81 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; - -function parsePositiveInt(value: string | null, fallback: number): number { - const parsed = Number.parseInt(value || "", 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function parseNonNegativeInt(value: string | null, fallback: number): number { - const parsed = Number.parseInt(value || "", 10); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; -} +import { parsePaginationParam } from "@/lib/api-pagination"; // GET /api/users/[username]/reviews - Get reviews for a user export async function GET( request: NextRequest, { params }: { params: Promise<{ username: string }> } ) { - try { + try { const { username } = await params; const supabase = await createClient(); const { searchParams } = new URL(request.url); - const limit = Math.min(parsePositiveInt(searchParams.get("limit"), 10), 50); - const offset = parseNonNegativeInt(searchParams.get("offset"), 0); + const limit = parsePaginationParam(searchParams.get("limit"), 10, 1, 50); + const offset = parsePaginationParam(searchParams.get("offset"), 0, 0, 100_000); // Get user ID from username - const { data: profile } = await supabase - .from("profiles") - .select("id") - .eq("username", username) - .single(); - + const { data: profile } = await supabase + .from("profiles") + .select("id") + .eq("username", username) + .single(); + if (!profile) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } - const { data: allRatings, error: ratingsError } = await supabase - .from("reviews") - .select("rating") - .eq("reviewee_id", profile.id); - - if (ratingsError) { - return NextResponse.json({ error: ratingsError.message }, { status: 400 }); - } - // Fetch reviews where this user is the reviewee const { data: reviews, error, count } = await supabase .from("reviews") .select( - ` - *, - reviewer:profiles!reviewer_id ( - id, - username, - full_name, - avatar_url - ), - gig:gigs ( - id, - title - ) - `, - { count: "exact" } - ) - .eq("reviewee_id", profile.id) - .order("created_at", { ascending: false }) - .range(offset, offset + limit - 1); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - + ` + *, + reviewer:profiles!reviewer_id ( + id, + username, + full_name, + avatar_url + ), + gig:gigs ( + id, + title + ) + `, + { count: "exact" } + ) + .eq("reviewee_id", profile.id) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + // Calculate average rating from all reviews const totalReviews = count || 0; let averageRating = 0; - if (allRatings && allRatings.length > 0) { - const sumRatings = allRatings.reduce((sum, r) => sum + r.rating, 0); - averageRating = sumRatings / allRatings.length; + if (reviews && reviews.length > 0) { + const sumRatings = reviews.reduce((sum, r) => sum + r.rating, 0); + averageRating = totalReviews > 0 ? sumRatings / reviews.length : 0; } - - return NextResponse.json({ - data: reviews, - summary: { - average_rating: averageRating, - total_reviews: totalReviews, - }, - pagination: { - total: totalReviews, - limit, - offset, - }, - }); - } catch { - return NextResponse.json( - { error: "An unexpected error occurred" }, - { status: 500 } - ); - } -} + + return NextResponse.json({ + data: reviews, + summary: { + average_rating: averageRating, + total_reviews: totalReviews, + }, + pagination: { + total: totalReviews, + limit, + offset, + }, + }); + } catch { + return NextResponse.json( + { error: "An unexpected error occurred" }, + { status: 500 } + ); + } +} diff --git a/src/lib/api-pagination.test.ts b/src/lib/api-pagination.test.ts new file mode 100644 index 00000000..11783624 --- /dev/null +++ b/src/lib/api-pagination.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { parsePaginationParam } from "./api-pagination"; + +describe("parsePaginationParam", () => { + it("uses the default for missing and non-numeric values", () => { + expect(parsePaginationParam(null, 20, 1, 50)).toBe(20); + expect(parsePaginationParam("abc", 20, 1, 50)).toBe(20); + }); + + it("clamps values to the allowed range", () => { + expect(parsePaginationParam("-5", 20, 0, 100_000)).toBe(0); + expect(parsePaginationParam("999", 20, 1, 50)).toBe(50); + }); + + it("truncates fractional values before clamping", () => { + expect(parsePaginationParam("12.9", 20, 1, 50)).toBe(12); + }); +}); diff --git a/src/lib/api-pagination.ts b/src/lib/api-pagination.ts new file mode 100644 index 00000000..6405ba0e --- /dev/null +++ b/src/lib/api-pagination.ts @@ -0,0 +1,14 @@ +export function parsePaginationParam( + value: string | null | undefined, + defaultValue: number, + min: number, + max: number +) { + if (value == null || value.trim() === "") return defaultValue; + + const parsed = Number(value); + if (!Number.isFinite(parsed)) return defaultValue; + + const whole = Math.trunc(parsed); + return Math.min(Math.max(whole, min), max); +}