feat(ratings): real reader ratings with no-login, 1-vote-per-session

Adds a no-auth star rating system backed by a centralized cross-property
table on supabase01.

Database:
- distribute.article_ratings (id, property, article_slug, rating 1..5,
  session_id, ip_hash, user_agent, created_at) with UNIQUE
  (property, article_slug, session_id). property column means every RMP
  site shares one table; AV Beat writes 'avbeat', BB writes 'bb', etc.
- distribute.article_rating_aggregates view rolls up real_count + real_avg
  per (property, slug).

API (/api/articles/rating):
- GET ?slug=X → { avg, count, my_rating } — reads aggregate, plus the
  current session's vote if any.
- POST { slug, rating } → inserts the vote; unique_violation on duplicate
  is silently swallowed (one vote per session). Returns updated summary.
- Session lives in `av_rate_sid` cookie (uuid, 1y, sameSite=lax). Cookie
  is minted by the API on first request, so the client never has to know
  about it.

StarRating component:
- Now interactive — hover preview, click to submit, "thanks!" affordance.
- Merges the existing deterministic baseline (slug-hash → 4.0–5.0 + age-
  scaled count) with the real aggregate using the baseline as a Bayesian
  prior, so a single real rating doesn't crater the displayed average.
- Once the current session has voted, stars show "your rating" and
  hover/click are disabled.
- Text colors updated #d0d0d0 → #0F172A so the rating number is readable
  on AV Beat's light backgrounds.

Property identifier hardcoded as "avbeat" at the top of the route — when
porting to BB / SNS / BLB / etc., change that constant and keep
everything else identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 19:32:28 +00:00
parent f44add70c7
commit ca317f26a9
2 changed files with 296 additions and 49 deletions

View File

@@ -0,0 +1,130 @@
/**
* Article star ratings — no-auth, one-vote-per-browser-session.
*
* GET /api/articles/rating?slug=<slug>
* → { avg: number|null, count: number, my_rating: number|null }
*
* POST /api/articles/rating
* body { slug: string, rating: 1..5 }
* → { ok: true, avg, count, my_rating }
*
* Storage: distribute.article_ratings (centralized RMP cross-property table).
* Each property writes with its own `property` value so the same table
* powers BB / AV Beat / SNS / etc. — see the top-level constant below.
*
* Session: a uuid stored in the `av_rate_sid` cookie (1y) — the (property,
* slug, session_id) tuple is UNIQUE so a duplicate POST from the same
* browser session for the same article is a no-op. Clearing cookies /
* opening incognito creates a new session, which is acceptable per the
* "one per computer within a session" spec.
*/
import { NextRequest, NextResponse } from "next/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { randomUUID, createHash } from "crypto";
export const dynamic = "force-dynamic";
const PROPERTY = "avbeat";
const SESSION_COOKIE = "av_rate_sid";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 365; // 1 year
function getOrMintSession(req: NextRequest): { sid: string; mint: boolean } {
const existing = req.cookies.get(SESSION_COOKIE)?.value;
if (existing && /^[0-9a-f-]{30,40}$/i.test(existing)) {
return { sid: existing, mint: false };
}
return { sid: randomUUID(), mint: true };
}
function hashIp(req: NextRequest): string | null {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| req.headers.get("x-real-ip")
|| null;
if (!ip) return null;
// SHA-256 truncated — enough for soft analytics, not reversible
return createHash("sha256").update(ip).digest("hex").slice(0, 32);
}
function attachSessionCookie(res: NextResponse, sid: string) {
res.cookies.set(SESSION_COOKIE, sid, {
maxAge: SESSION_TTL_SECONDS,
sameSite: "lax",
path: "/",
httpOnly: false, // client JS may read it for optimistic UI
});
}
async function loadSummary(slug: string, sid: string) {
const sb = createAdminClient("distribute");
const [agg, mine] = await Promise.all([
sb.from("article_rating_aggregates")
.select("real_count, real_avg")
.eq("property", PROPERTY)
.eq("article_slug", slug)
.maybeSingle(),
sb.from("article_ratings")
.select("rating")
.eq("property", PROPERTY)
.eq("article_slug", slug)
.eq("session_id", sid)
.maybeSingle(),
]);
return {
avg: agg.data?.real_avg ?? null,
count: agg.data?.real_count ?? 0,
my_rating: mine.data?.rating ?? null,
};
}
export async function GET(req: NextRequest) {
const slug = new URL(req.url).searchParams.get("slug");
if (!slug) {
return NextResponse.json({ error: "slug required" }, { status: 400 });
}
const { sid, mint } = getOrMintSession(req);
const summary = await loadSummary(slug, sid);
const res = NextResponse.json(summary);
if (mint) attachSessionCookie(res, sid);
return res;
}
export async function POST(req: NextRequest) {
let body: { slug?: unknown; rating?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json" }, { status: 400 });
}
const slug = typeof body.slug === "string" ? body.slug : null;
const rating = typeof body.rating === "number" ? Math.round(body.rating) : null;
if (!slug || !rating || rating < 1 || rating > 5) {
return NextResponse.json({ error: "slug + rating (1..5) required" }, { status: 400 });
}
const { sid, mint } = getOrMintSession(req);
const sb = createAdminClient("distribute");
// Insert; on conflict (property, slug, session_id) we silently no-op
// because the spec says one rating per session. We then return the
// CURRENT summary so the client renders what's actually stored.
const { error } = await sb
.from("article_ratings")
.insert({
property: PROPERTY,
article_slug: slug,
rating,
session_id: sid,
ip_hash: hashIp(req),
user_agent: req.headers.get("user-agent")?.slice(0, 200) ?? null,
});
// 23505 = unique_violation — duplicate vote from same session, expected
if (error && (error as { code?: string }).code !== "23505") {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const summary = await loadSummary(slug, sid);
const res = NextResponse.json({ ok: true, ...summary });
if (mint) attachSessionCookie(res, sid);
return res;
}

View File

@@ -1,19 +1,42 @@
/** /**
* StarRating — deterministic, displays as ★★★★½ 4.7 (212 ratings) * StarRating — interactive reader ratings.
* *
* Rating + vote count are derived from a stable hash of the article's * Display: ★★★★½ 4.7 (212 ratings)
* slug, so the same article always renders the same numbers across views. *
* Rating range is intentionally constrained to 4.05.0 (always positive) * Two layers of state:
* per editorial spec; vote count scales with article age when a date is * 1. A deterministic baseline derived from the article's slug (4.05.0
* supplied. * rating, plausibility-scaled vote count by article age). This shows
* immediately on render and gives every story some social-proof
* density even before real ratings come in.
* 2. A real aggregate fetched from /api/articles/rating?slug=… on mount.
* When real ratings exist, they MERGE with the baseline using the
* baseline as a synthetic prior — see mergeRatings() — so a single
* contrarian vote can't crater an article's average.
*
* Rating gate:
* - No login required.
* - One vote per browser session per article — enforced server-side
* via a UNIQUE (property, slug, session_id) index. The session_id
* lives in the `av_rate_sid` cookie minted by the API on first
* request.
* - Once a session has voted, the stars become non-interactive and
* show "your rating: N" alongside the aggregate.
*
* The widget can render in three sizes (sm/md/lg) and can hide the
* "(212 ratings)" count via showCount=false for tight feed layouts.
*/ */
"use client";
import { useEffect, useMemo, useState } from "react";
interface Props { interface Props {
seed: string; // article slug or any stable identifier seed: string; // article slug or any stable identifier
publishedAt?: string | null; // ISO/parseable date; affects vote count publishedAt?: string | null;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
showCount?: boolean; showCount?: boolean;
className?: string; className?: string;
/** When true (default) the widget fetches the real rating and lets the
* reader vote. Set false for static contexts (sitemap previews, etc.). */
interactive?: boolean;
} }
function hash32(s: string): number { function hash32(s: string): number {
@@ -25,51 +48,81 @@ function hash32(s: string): number {
return h >>> 0; return h >>> 0;
} }
function derive(seed: string, publishedAt?: string | null) { function deriveBaseline(seed: string, publishedAt?: string | null) {
const h = hash32(seed || "default"); const h = hash32(seed || "default");
// Rating 4.05.0 quantised to 0.1 increments (so we get ★★★★½ shapes that const stepsAbove4 = (h % 11) / 10;
// line up with the 0.5 fractional rendering). const baseRating = Math.round((4 + stepsAbove4) * 10) / 10;
const stepsAbove4 = (h % 11) / 10; // 0.0, 0.1, … 1.0
const rating = Math.round((4 + stepsAbove4) * 10) / 10;
// Vote count: scale by article age when date is parseable, else use slug
// hash directly for a plausible spread.
let ageDays = 30; let ageDays = 30;
if (publishedAt) { if (publishedAt) {
const t = new Date(publishedAt).getTime(); const t = new Date(publishedAt).getTime();
if (!Number.isNaN(t)) ageDays = Math.max(1, (Date.now() - t) / 86_400_000); if (!Number.isNaN(t)) ageDays = Math.max(1, (Date.now() - t) / 86_400_000);
} }
// Newer articles: 1580. Mid (30180 days): 50250. Older: 80500. const base =
const base = ageDays < 14 ? 15 + (h % 65) ageDays < 14 ? 15 + (h % 65) :
: ageDays < 180 ? 50 + (h % 200) ageDays < 180 ? 50 + (h % 200) :
: 80 + (h % 420); 80 + (h % 420);
const count = Math.round(base); return { baseRating, baseCount: Math.round(base) };
return { rating, count };
} }
function Stars({ value, sizePx }: { value: number; sizePx: number }) { /**
// Render 5 stars with the proportional "value" amount filled. * Bayesian-ish merge: treat the deterministic baseline as a synthetic
const pct = Math.max(0, Math.min(5, value)) / 5 * 100; * prior so a real rating doesn't whiplash the displayed average.
* displayed_avg = (baseline_count * baseline_avg + real_sum) / (baseline_count + real_count)
*/
function mergeRatings(
baseline: { baseRating: number; baseCount: number },
realAvg: number | null,
realCount: number,
) {
if (realAvg === null || realCount === 0) {
return { avg: baseline.baseRating, count: baseline.baseCount };
}
const totalCount = baseline.baseCount + realCount;
const totalSum = baseline.baseCount * baseline.baseRating + realAvg * realCount;
return {
avg: Math.round((totalSum / totalCount) * 10) / 10,
count: totalCount,
};
}
function Stars({
value, hoverValue, sizePx, onHover, onClick, interactive,
}: {
value: number;
hoverValue: number | null;
sizePx: number;
onHover: (n: number | null) => void;
onClick: (n: number) => void;
interactive: boolean;
}) {
const displayed = hoverValue ?? value;
const pct = Math.max(0, Math.min(5, displayed)) / 5 * 100;
const w = sizePx * 5 + 8; const w = sizePx * 5 + 8;
return ( return (
<span <span
style={{ position: "relative", display: "inline-block", height: sizePx, width: w, lineHeight: 1 }} style={{ position: "relative", display: "inline-block", height: sizePx, width: w, lineHeight: 1, cursor: interactive ? "pointer" : "default" }}
onMouseLeave={() => interactive && onHover(null)}
aria-hidden="true" aria-hidden="true"
> >
<span <span style={{ position: "absolute", inset: 0, color: "#3a3a3a", fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif" }}></span>
style={{ <span style={{ position: "absolute", inset: 0, color: "#f5c518", fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif", width: `${pct}%`, overflow: "hidden", whiteSpace: "nowrap", transition: "width 80ms ease" }}></span>
position: "absolute", inset: 0, color: "#3a3a3a", {interactive && (
fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif", <span style={{ position: "absolute", inset: 0, display: "flex" }}>
}} {[1, 2, 3, 4, 5].map((n) => (
></span> <button
<span key={n}
style={{ type="button"
position: "absolute", inset: 0, color: "#f5c518", aria-label={`Rate ${n} ${n === 1 ? "star" : "stars"}`}
fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif", onMouseEnter={() => onHover(n)}
width: `${pct}%`, overflow: "hidden", whiteSpace: "nowrap", onFocus={() => onHover(n)}
}} onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClick(n); }}
></span> style={{ flex: 1, height: "100%", background: "transparent", border: "none", padding: 0, cursor: "pointer", color: "transparent" }}
>
{n}
</button>
))}
</span>
)}
</span> </span>
); );
} }
@@ -80,22 +133,86 @@ export default function StarRating({
size = "md", size = "md",
showCount = true, showCount = true,
className = "", className = "",
interactive = true,
}: Props) { }: Props) {
const { rating, count } = derive(seed, publishedAt); const baseline = useMemo(() => deriveBaseline(seed, publishedAt), [seed, publishedAt]);
const [realAvg, setRealAvg] = useState<number | null>(null);
const [realCount, setRealCount] = useState(0);
const [myRating, setMyRating] = useState<number | null>(null);
const [hoverValue, setHoverValue] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [justRated, setJustRated] = useState(false);
const merged = mergeRatings(baseline, realAvg, realCount);
const displayedValue = myRating ?? merged.avg;
const sizePx = size === "sm" ? 11 : size === "lg" ? 16 : 13; const sizePx = size === "sm" ? 11 : size === "lg" ? 16 : 13;
const textSize = size === "sm" ? "text-[10px]" : size === "lg" ? "text-sm" : "text-xs"; const textSize = size === "sm" ? "text-[10px]" : size === "lg" ? "text-sm" : "text-xs";
// Fetch the real rating once on mount (and only when interactive — static
// contexts don't need the round-trip).
useEffect(() => {
if (!interactive || !seed) return;
let aborted = false;
fetch(`/api/articles/rating?slug=${encodeURIComponent(seed)}`)
.then((r) => r.ok ? r.json() : null)
.then((d) => {
if (aborted || !d) return;
setRealAvg(typeof d.avg === "number" ? d.avg : null);
setRealCount(typeof d.count === "number" ? d.count : 0);
setMyRating(typeof d.my_rating === "number" ? d.my_rating : null);
})
.catch(() => { /* silent — falls back to baseline */ });
return () => { aborted = true; };
}, [seed, interactive]);
async function submit(n: number) {
if (submitting || myRating !== null) return;
setSubmitting(true);
// Optimistic — show their stars immediately
setMyRating(n);
setJustRated(true);
try {
const res = await fetch(`/api/articles/rating`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ slug: seed, rating: n }),
});
if (res.ok) {
const d = await res.json();
setRealAvg(typeof d.avg === "number" ? d.avg : null);
setRealCount(typeof d.count === "number" ? d.count : 0);
setMyRating(typeof d.my_rating === "number" ? d.my_rating : n);
}
} finally {
setSubmitting(false);
}
}
const ariaLabel = myRating !== null
? `You rated this ${myRating} out of 5 stars. Aggregate ${merged.avg.toFixed(1)} from ${merged.count} readers.`
: `Rated ${merged.avg.toFixed(1)} out of 5 stars by ${merged.count} ${merged.count === 1 ? "reader" : "readers"}. Click to rate.`;
const canVote = interactive && myRating === null && !submitting;
return ( return (
<span <span className={`inline-flex items-center gap-1.5 ${className}`} role="img" aria-label={ariaLabel}>
className={`inline-flex items-center gap-1.5 ${className}`} <Stars
role="img" value={displayedValue}
aria-label={`Rated ${rating} out of 5 stars by ${count} ${count === 1 ? "reader" : "readers"}`} hoverValue={canVote ? hoverValue : null}
> sizePx={sizePx}
<Stars value={rating} sizePx={sizePx} /> onHover={setHoverValue}
<span className={`font-body font-semibold text-[#d0d0d0] ${textSize}`}>{rating.toFixed(1)}</span> onClick={submit}
interactive={canVote}
/>
<span className={`font-body font-semibold text-[#0F172A] ${textSize}`}>{displayedValue.toFixed(1)}</span>
{showCount && ( {showCount && (
<span className={`font-body text-[#666] ${textSize}`}> <span className={`font-body text-[#64748B] ${textSize}`}>
({count.toLocaleString()} {count === 1 ? "rating" : "ratings"}) ({merged.count.toLocaleString()} {merged.count === 1 ? "rating" : "ratings"})
</span>
)}
{justRated && (
<span className={`font-body text-[#1D4ED8] font-semibold ${textSize}`} aria-live="polite">
thanks!
</span> </span>
)} )}
</span> </span>