From ee13b92d13d9b3695b2d0254e376c5b1befc5924 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 10:32:46 +0000 Subject: [PATCH 1/2] [#94] Add comment length validation and GET pagination - Server-side 500-char comment limit with client-side maxLength - GET endpoint now supports ?limit=N&offset=N pagination (default 20, max 100) with total count in response - Results ordered by updated_at descending Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/ratings/route.ts | 25 +++++++++++++++++++++---- src/components/RatingWidget.tsx | 1 + 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/app/api/ratings/route.ts b/src/app/api/ratings/route.ts index c5099dbc..d70cf49c 100644 --- a/src/app/api/ratings/route.ts +++ b/src/app/api/ratings/route.ts @@ -4,6 +4,8 @@ import { publicClient } from "../../../../lib/rpc"; import { createServerClient, supabase } from "../../../../lib/supabase"; import { erc20Abi } from "../../../../lib/price"; +const MAX_COMMENT_LENGTH = 500; + function error(message: string, status = 400) { return NextResponse.json({ error: message }, { status }); } @@ -23,16 +25,28 @@ export async function GET(req: NextRequest) { return error("Supabase not configured", 500); } + const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 20), 100); + const offset = Math.max(Number(req.nextUrl.searchParams.get("offset") ?? 0), 0); + // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data, error: dbError } = await (db.from("ratings") as any) - .select("*") - .eq("storyline_id", Number(storylineId)); + const query = (db.from("ratings") as any) + .select("*", { count: "exact" }) + .eq("storyline_id", Number(storylineId)) + .order("updated_at", { ascending: false }) + .range(offset, offset + limit - 1); + + const { data, error: dbError, count: totalCount } = await query; if (dbError) { return error(`Database error: ${dbError.message}`, 500); } const ratings = data ?? []; + const total = totalCount ?? 0; + + // Compute average from the page — for a true average we'd need a separate + // aggregate query, but for now this matches the prior behaviour when all + // ratings fit in one page. const average = ratings.length > 0 ? ratings.reduce( @@ -41,7 +55,7 @@ export async function GET(req: NextRequest) { ) / ratings.length : 0; - return NextResponse.json({ ratings, average, count: ratings.length }); + return NextResponse.json({ ratings, average, count: ratings.length, total, limit, offset }); } // --------------------------------------------------------------------------- @@ -77,6 +91,9 @@ export async function POST(req: NextRequest) { if (!address || !signature || !message) { return error("Missing address, signature, or message"); } + if (comment && comment.length > MAX_COMMENT_LENGTH) { + return error(`Comment must be ${MAX_COMMENT_LENGTH} characters or fewer`); + } // Validate signed message binds to this specific action (including comment) const boundComment = comment ?? ""; diff --git a/src/components/RatingWidget.tsx b/src/components/RatingWidget.tsx index dd6378ba..83f47eca 100644 --- a/src/components/RatingWidget.tsx +++ b/src/components/RatingWidget.tsx @@ -187,6 +187,7 @@ export function RatingWidget({ storylineId, tokenAddress }: RatingWidgetProps) { value={comment} onChange={(e) => setComment(e.target.value)} disabled={submitting} + maxLength={500} rows={2} className="border-border bg-background text-foreground mt-2 w-full resize-none rounded border px-3 py-2 text-sm focus:border-accent focus:outline-none disabled:opacity-50" /> From d433c64463cb757533f66abe44c9820716d32bee Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 10:35:39 +0000 Subject: [PATCH 2/2] [#94] Fix average/count to be global and add myRating lookup Average and count now computed from all ratings (not just the current page). Added optional raterAddress param to GET for dedicated lookup of the caller's own rating, used by RatingWidget for prefill. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/ratings/route.ts | 52 ++++++++++++++++++++++----------- src/components/RatingWidget.tsx | 11 +++---- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/src/app/api/ratings/route.ts b/src/app/api/ratings/route.ts index d70cf49c..e328cbe8 100644 --- a/src/app/api/ratings/route.ts +++ b/src/app/api/ratings/route.ts @@ -27,35 +27,53 @@ export async function GET(req: NextRequest) { const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 20), 100); const offset = Math.max(Number(req.nextUrl.searchParams.get("offset") ?? 0), 0); + const sid = Number(storylineId); + // Fetch all ratings for global average/count, then slice for pagination // eslint-disable-next-line @typescript-eslint/no-explicit-any - const query = (db.from("ratings") as any) - .select("*", { count: "exact" }) - .eq("storyline_id", Number(storylineId)) + const { data: allData, error: allError } = await (db.from("ratings") as any) + .select("rating") + .eq("storyline_id", sid); + + if (allError) { + return error(`Database error: ${allError.message}`, 500); + } + + const allRatings = allData ?? []; + const count = allRatings.length; + const average = + count > 0 + ? allRatings.reduce((sum: number, r: { rating: number }) => sum + r.rating, 0) / count + : 0; + + // Paginated query for full rating objects + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error: dbError } = await (db.from("ratings") as any) + .select("*") + .eq("storyline_id", sid) .order("updated_at", { ascending: false }) .range(offset, offset + limit - 1); - const { data, error: dbError, count: totalCount } = await query; - if (dbError) { return error(`Database error: ${dbError.message}`, 500); } const ratings = data ?? []; - const total = totalCount ?? 0; - // Compute average from the page — for a true average we'd need a separate - // aggregate query, but for now this matches the prior behaviour when all - // ratings fit in one page. - const average = - ratings.length > 0 - ? ratings.reduce( - (sum: number, r: { rating: number }) => sum + r.rating, - 0, - ) / ratings.length - : 0; + // Optionally look up the caller's own rating (may not be on current page) + const raterAddress = req.nextUrl.searchParams.get("raterAddress"); + let myRating: unknown = null; + if (raterAddress) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: mine } = await (db.from("ratings") as any) + .select("*") + .eq("storyline_id", sid) + .eq("rater_address", raterAddress.toLowerCase()) + .single(); + myRating = mine ?? null; + } - return NextResponse.json({ ratings, average, count: ratings.length, total, limit, offset }); + return NextResponse.json({ ratings, average, count, limit, offset, myRating }); } // --------------------------------------------------------------------------- diff --git a/src/components/RatingWidget.tsx b/src/components/RatingWidget.tsx index 83f47eca..72cdca1d 100644 --- a/src/components/RatingWidget.tsx +++ b/src/components/RatingWidget.tsx @@ -21,6 +21,7 @@ interface RatingsResponse { ratings: RatingData[]; average: number; count: number; + myRating: RatingData | null; } interface RatingWidgetProps { @@ -60,9 +61,11 @@ export function RatingWidget({ storylineId, tokenAddress }: RatingWidgetProps) { // Fetch ratings const { data: ratingsData, refetch } = useQuery({ - queryKey: ["ratings", storylineId], + queryKey: ["ratings", storylineId, address], queryFn: async () => { - const res = await fetch(`/api/ratings?storylineId=${storylineId}`); + const params = new URLSearchParams({ storylineId: String(storylineId) }); + if (address) params.set("raterAddress", address); + const res = await fetch(`/api/ratings?${params}`); if (!res.ok) throw new Error("Failed to fetch ratings"); return res.json(); }, @@ -87,9 +90,7 @@ export function RatingWidget({ storylineId, tokenAddress }: RatingWidgetProps) { // Pre-fill existing rating or reset when wallet changes useEffect(() => { if (ratingsData && address) { - const existing = ratingsData.ratings.find( - (r) => r.rater_address === address.toLowerCase(), - ); + const existing = ratingsData.myRating; if (existing) { setSelectedRating(existing.rating); setComment(existing.comment ?? "");