-
Notifications
You must be signed in to change notification settings - Fork 32
fix(api): clamp activity and review pagination #339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ralyodio
merged 1 commit into
profullstack:master
from
Jorel97:codex/fix-activity-review-pagination-338
May 29, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
average_ratingcomputed from page slice, not all reviewsThe old code issued a second Supabase query (
allRatings) that fetched everyratingrow for the reviewee before computing the mean. That query was removed here;reviewsis now the page-sized subset returned by.range(offset, offset + limit - 1). For a user with 50 total reviews and the defaultlimit=10,averageRatingwill reflect only the 10 reviews on the current page, returning a different (and wrong) value on every page. The comment on line 55 still says "from all reviews" but that is no longer true.