Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/app/api/activity/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/app/api/reviews/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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")
Expand Down
125 changes: 58 additions & 67 deletions src/app/api/users/[username]/activity/route.ts
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 }
);
}
}
130 changes: 56 additions & 74 deletions src/app/api/users/[username]/reviews/route.ts
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;
}
Comment on lines 55 to 61
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 average_rating computed from page slice, not all reviews

The old code issued a second Supabase query (allRatings) that fetched every rating row for the reviewee before computing the mean. That query was removed here; reviews is now the page-sized subset returned by .range(offset, offset + limit - 1). For a user with 50 total reviews and the default limit=10, averageRating will 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.

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 }
);
}
}
18 changes: 18 additions & 0 deletions src/lib/api-pagination.test.ts
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);
});
});
14 changes: 14 additions & 0 deletions src/lib/api-pagination.ts
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);
}
Loading