From 97dac17d76d9038899b250938e8a4032ded121e7 Mon Sep 17 00:00:00 2001 From: dommango-sys Date: Tue, 30 Jun 2026 17:46:18 -0400 Subject: [PATCH 1/7] feat: platform super-admin analytics dashboard with engagement event tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an admin-only analytics dashboard (/admin/analytics) tracking user engagement across all pools, plus the event-logging layer that feeds it. - AnalyticsEvent model + enum (audit-style plain-id FKs, no relations/cascade, indexed by day/type/user) — additive and non-breaking, mirroring the PrizeAward/Feedback precedent. Migration + regenerated client included. - logEvent: best-effort write that never throws or breaks the triggering action (mirrors notifyPool). Instrumented at sign-in/up, pool create, pool join (code + invite, new members only), bracket submit, chat message, reaction. - Pure, unit-tested rollups (UTC day bucketing, DAU/WAU/MAU, type breakdown, top pools, stickiness) split from the DB query layer. - Dashboard gated by getTournamentAdmin (fail-closed), custom Tailwind bar charts (no new deps), linked from the admin page. MAU/stickiness fetch window covers the full rolling 30d boundary so late-day actives aren't clipped. --- app/admin/analytics/AnalyticsDashboard.tsx | 168 ++++++++++++++++++ app/admin/analytics/page.tsx | 36 ++++ app/admin/page.tsx | 6 + app/api/pool/[id]/chat/react/route.ts | 3 + app/api/pool/[id]/chat/route.ts | 2 + auth.ts | 12 +- lib/analytics/aggregate.test.ts | 139 +++++++++++++++ lib/analytics/aggregate.ts | 117 ++++++++++++ lib/analytics/events.ts | 36 ++++ lib/analytics/queries.ts | 90 ++++++++++ lib/pool/invites.ts | 8 + lib/pool/manage.ts | 17 ++ lib/pool/submit-picks.ts | 15 ++ .../migration.sql | 25 +++ prisma/schema.prisma | 33 ++++ 15 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 app/admin/analytics/AnalyticsDashboard.tsx create mode 100644 app/admin/analytics/page.tsx create mode 100644 lib/analytics/aggregate.test.ts create mode 100644 lib/analytics/aggregate.ts create mode 100644 lib/analytics/events.ts create mode 100644 lib/analytics/queries.ts create mode 100644 prisma/migrations/20260630000000_analytics_events/migration.sql diff --git a/app/admin/analytics/AnalyticsDashboard.tsx b/app/admin/analytics/AnalyticsDashboard.tsx new file mode 100644 index 0000000..150755e --- /dev/null +++ b/app/admin/analytics/AnalyticsDashboard.tsx @@ -0,0 +1,168 @@ +import type { AdminAnalytics } from "@/lib/analytics/queries"; +import type { AnalyticsEventType } from "@/generated/prisma/enums"; +import type { DayBucket } from "@/lib/analytics/aggregate"; + +const LABEL = "text-xs font-bold uppercase tracking-[0.08em] text-ink-3"; + +// Friendly names for the raw event enum, used in the breakdown. +const TYPE_LABEL: Record = { + SIGN_IN: "Sign-ins", + SIGN_UP: "Sign-ups", + POOL_CREATE: "Pools created", + POOL_JOIN: "Pool joins", + ENTRY_SUBMIT: "Brackets submitted", + CHAT_MESSAGE: "Chat messages", + REACTION: "Reactions", +}; + +function fmt(n: number): string { + return n.toLocaleString("en-US"); +} + +// A short axis label for a UTC day-key ("2026-06-30" → "Jun 30"). +function shortDate(key: string): string { + const [, m, d] = key.split("-"); + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + return `${months[Number(m) - 1]} ${Number(d)}`; +} + +function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( +
+

{label}

+

{value}

+ {hint ?

{hint}

: null} +
+ ); +} + +// A zero-filled daily series as vertical bars. Heights are relative to the +// series max; days with no activity render as a faint baseline tick. Sparse +// axis labels (first / middle / last) keep 30 columns readable. +function DailyBars({ data, color }: { data: DayBucket[]; color: string }) { + const max = Math.max(1, ...data.map((d) => d.count)); + const mid = Math.floor(data.length / 2); + return ( +
+
+ {data.map((d) => ( +
0 ? 4 : 1.5)}%`, + background: d.count > 0 ? color : "var(--color-line)", + opacity: d.count > 0 ? 1 : 0.6, + }} + /> + ))} +
+
+ {data.length ? shortDate(data[0].date) : ""} + {data.length ? shortDate(data[mid].date) : ""} + {data.length ? shortDate(data[data.length - 1].date) : ""} +
+
+ ); +} + +// A horizontal labelled bar (event-type breakdown, top pools). +function HBar({ label, count, max, color }: { label: string; count: number; max: number; color: string }) { + return ( +
+ {label} +
+ 0 ? (count / max) * 100 : 0}%`, background: color }} + /> +
+ + {fmt(count)} + +
+ ); +} + +function Section({ title, children, note }: { title: string; note?: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {note ?

{note}

: null} +
+ {children} +
+
+ ); +} + +export function AnalyticsDashboard({ data }: { data: AdminAnalytics }) { + const { totals, newUsers, active, eventsPerDay, activeUsersPerDay, byType, topPools, windowDays } = data; + const typeMax = Math.max(1, ...byType.map((t) => t.count)); + const poolMax = Math.max(1, ...topPools.map((p) => p.count)); + + return ( +
+ {/* ACTIVE USERS */} +
+
+ + + + +
+
+

Active users per day

+
+ +
+
+
+ + {/* ENGAGEMENT VOLUME */} +
+ + {byType.length > 0 ? ( +
+

By type

+ {byType.map((t) => ( + + ))} +
+ ) : ( +

+ No engagement events recorded yet — metrics populate as users sign in and play. +

+ )} +
+ + {/* MOST ACTIVE POOLS */} + {topPools.length > 0 ? ( +
+
+ {topPools.map((p) => ( + + ))} +
+
+ ) : null} + + {/* TOTALS */} +
+
+ + + + +
+
+
+ ); +} diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx new file mode 100644 index 0000000..9594a95 --- /dev/null +++ b/app/admin/analytics/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { getTournamentAdmin } from "@/lib/pool/access"; +import { getAdminAnalytics } from "@/lib/analytics/queries"; +import { AnalyticsDashboard } from "./AnalyticsDashboard"; + +export const dynamic = "force-dynamic"; + +export default async function AnalyticsPage() { + const admin = await getTournamentAdmin(); + if (!admin) redirect("/signin?error=forbidden"); + + const data = await getAdminAnalytics(); + + return ( +
+
+
+

Admin · Analytics

+

Platform metrics

+

User engagement across all pools · last {data.windowDays} days

+
+ + ← Admin + +
+ +
+ +
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index b3fc367..c21fb3f 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -92,6 +92,12 @@ export default async function AdminPage() { > Challenge prizes → + + Analytics → + {/* GROUP STANDINGS */} diff --git a/app/api/pool/[id]/chat/react/route.ts b/app/api/pool/[id]/chat/react/route.ts index 0a8e46a..ce9f95e 100644 --- a/app/api/pool/[id]/chat/react/route.ts +++ b/app/api/pool/[id]/chat/react/route.ts @@ -7,6 +7,7 @@ import { toggleReaction } from "@/lib/pool/chat"; import { notifyPool } from "@/lib/realtime/notify"; import { rateLimit } from "@/lib/rate-limit"; import { apiOk, apiError } from "@/lib/api"; +import { logEvent } from "@/lib/analytics/events"; export const dynamic = "force-dynamic"; @@ -42,6 +43,8 @@ export async function POST( try { const active = await toggleReaction(poolId, input.messageId, access.user.id, input.emoji); await notifyPool(poolId, "chat"); + // Count only added reactions (not un-reacts) as engagement. Best-effort. + if (active) await logEvent({ type: "REACTION", userId: access.user.id, poolId, metadata: { emoji: input.emoji } }); return apiOk({ active }); } catch (err) { return apiError((err as Error).message, 422); diff --git a/app/api/pool/[id]/chat/route.ts b/app/api/pool/[id]/chat/route.ts index 5587cbe..85be6ce 100644 --- a/app/api/pool/[id]/chat/route.ts +++ b/app/api/pool/[id]/chat/route.ts @@ -8,6 +8,7 @@ import { listMessages, postMessage } from "@/lib/pool/chat"; import { notifyPool } from "@/lib/realtime/notify"; import { rateLimit } from "@/lib/rate-limit"; import { apiOk, apiError } from "@/lib/api"; +import { logEvent } from "@/lib/analytics/events"; export const dynamic = "force-dynamic"; @@ -88,6 +89,7 @@ export async function POST( attachmentType: input.attachmentType, }); await notifyPool(poolId, "chat"); + await logEvent({ type: "CHAT_MESSAGE", userId: access.user.id, poolId }); return apiOk(message, { status: 201 }); } catch (err) { return apiError((err as Error).message, 422); diff --git a/auth.ts b/auth.ts index 6979754..e911e35 100644 --- a/auth.ts +++ b/auth.ts @@ -18,6 +18,7 @@ import { createTransport } from "nodemailer"; import { prisma } from "@/lib/db"; import { env, googleEnabled, facebookEnabled, emailEnabled } from "@/lib/env"; import { claimEntriesForUser } from "@/lib/auth/claim"; +import { logEvent } from "@/lib/analytics/events"; // Magic-link email body. A well-formed multipart message (greeting, a single // clear CTA, an expiry/ignore note, sender identity) scores far better with spam @@ -135,9 +136,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, events: { // Bind any entries imported for this email on every sign-in (idempotent). - async signIn({ user, account }) { + async signIn({ user, account, isNewUser }) { if (!user?.id) return; await claimEntriesForUser(user.id, user.email); + // Engagement signal: SIGN_IN every time (the DAU/WAU/MAU basis), plus a + // one-off SIGN_UP on the user's first sign-in. Best-effort — a logging + // failure never breaks sign-in. + await logEvent({ + type: "SIGN_IN", + userId: user.id, + metadata: account?.provider ? { provider: account.provider } : undefined, + }); + if (isNewUser) await logEvent({ type: "SIGN_UP", userId: user.id }); // OAuth providers (Google/Facebook) only ever return a provider-verified // email, but the Prisma adapter doesn't populate emailVerified for them // (only the magic-link does). Stamp it on first OAuth sign-in so the prize diff --git a/lib/analytics/aggregate.test.ts b/lib/analytics/aggregate.test.ts new file mode 100644 index 0000000..68ba0f2 --- /dev/null +++ b/lib/analytics/aggregate.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { + dayKey, + startOfDayUtcDaysAgo, + eventsPerDay, + activeUsersPerDay, + distinctActiveUsers, + countByType, + topPoolsByEvents, + stickiness, + type EventRow, +} from "./aggregate"; +import type { AnalyticsEventType } from "@/generated/prisma/enums"; + +// A fixed clock so every window/bucket assertion is deterministic. +const NOW = new Date("2026-06-30T12:00:00.000Z"); + +function ev( + type: AnalyticsEventType, + iso: string, + userId: string | null = null, + poolId: string | null = null, +): EventRow { + return { type, userId, poolId, createdAt: new Date(iso) }; +} + +describe("dayKey / startOfDayUtcDaysAgo", () => { + it("formats a UTC day key", () => { + expect(dayKey(new Date("2026-06-30T23:59:59.000Z"))).toBe("2026-06-30"); + }); + + it("walks back whole UTC days from midnight", () => { + expect(startOfDayUtcDaysAgo(NOW, 0).toISOString()).toBe("2026-06-30T00:00:00.000Z"); + expect(startOfDayUtcDaysAgo(NOW, 6).toISOString()).toBe("2026-06-24T00:00:00.000Z"); + }); +}); + +describe("eventsPerDay", () => { + it("zero-fills and orders oldest → newest", () => { + const rows = [ + ev("SIGN_IN", "2026-06-30T01:00:00Z"), + ev("CHAT_MESSAGE", "2026-06-30T09:00:00Z"), + ev("SIGN_IN", "2026-06-28T10:00:00Z"), + ]; + const out = eventsPerDay(rows, 3, NOW); + expect(out.map((d) => d.date)).toEqual(["2026-06-28", "2026-06-29", "2026-06-30"]); + expect(out.map((d) => d.count)).toEqual([1, 0, 2]); + }); + + it("ignores rows older than the window", () => { + const rows = [ev("SIGN_IN", "2026-06-20T10:00:00Z"), ev("SIGN_IN", "2026-06-30T10:00:00Z")]; + const out = eventsPerDay(rows, 3, NOW); + expect(out.reduce((a, d) => a + d.count, 0)).toBe(1); + }); +}); + +describe("activeUsersPerDay", () => { + it("counts a user once per day and skips anonymous rows", () => { + const rows = [ + ev("SIGN_IN", "2026-06-30T01:00:00Z", "u1"), + ev("CHAT_MESSAGE", "2026-06-30T02:00:00Z", "u1"), // same user, same day + ev("SIGN_IN", "2026-06-30T03:00:00Z", "u2"), + ev("REACTION", "2026-06-30T04:00:00Z", null), // anonymous → ignored + ]; + const out = activeUsersPerDay(rows, 1, NOW); + expect(out).toEqual([{ date: "2026-06-30", count: 2 }]); + }); +}); + +describe("distinctActiveUsers (DAU/WAU/MAU)", () => { + const rows = [ + ev("SIGN_IN", "2026-06-30T06:00:00Z", "u1"), // within 24h + ev("SIGN_IN", "2026-06-27T06:00:00Z", "u2"), // within 7d + ev("SIGN_IN", "2026-06-10T06:00:00Z", "u3"), // within 30d + ev("SIGN_IN", "2026-05-01T06:00:00Z", "u4"), // older than 30d + ev("SIGN_IN", "2026-06-30T07:00:00Z", null), // anonymous + ]; + + it("DAU counts only the last 24h", () => { + expect(distinctActiveUsers(rows, NOW, 1)).toBe(1); + }); + it("WAU counts the last 7 days", () => { + expect(distinctActiveUsers(rows, NOW, 7)).toBe(2); + }); + it("MAU counts the last 30 days and dedupes users", () => { + const dupd = [...rows, ev("CHAT_MESSAGE", "2026-06-29T06:00:00Z", "u1")]; + expect(distinctActiveUsers(dupd, NOW, 30)).toBe(3); + }); + + it("uses a half-open window (now-N, now] — the fetch must reach back this far", () => { + const since = new Date(NOW.getTime() - 30 * 24 * 60 * 60 * 1000); // exactly 30d ago + const justInside = new Date(since.getTime() + 1); + const justOutside = since; + expect(distinctActiveUsers([ev("SIGN_IN", justInside.toISOString(), "u9")], NOW, 30)).toBe(1); + expect(distinctActiveUsers([ev("SIGN_IN", justOutside.toISOString(), "u9")], NOW, 30)).toBe(0); + }); +}); + +describe("countByType", () => { + it("totals per type, descending with stable tie-break", () => { + const rows = [ + ev("SIGN_IN", "2026-06-30T01:00:00Z"), + ev("SIGN_IN", "2026-06-30T02:00:00Z"), + ev("REACTION", "2026-06-30T03:00:00Z"), + ev("CHAT_MESSAGE", "2026-06-30T04:00:00Z"), + ]; + expect(countByType(rows)).toEqual([ + { type: "SIGN_IN", count: 2 }, + { type: "CHAT_MESSAGE", count: 1 }, // tie with REACTION → alpha order + { type: "REACTION", count: 1 }, + ]); + }); +}); + +describe("topPoolsByEvents", () => { + it("ranks pools by volume, skips null poolId, respects the limit", () => { + const rows = [ + ev("CHAT_MESSAGE", "2026-06-30T01:00:00Z", "u1", "pA"), + ev("CHAT_MESSAGE", "2026-06-30T02:00:00Z", "u2", "pA"), + ev("REACTION", "2026-06-30T03:00:00Z", "u1", "pB"), + ev("SIGN_IN", "2026-06-30T04:00:00Z", "u1", null), // no pool → ignored + ]; + expect(topPoolsByEvents(rows, 1)).toEqual([{ poolId: "pA", count: 2 }]); + expect(topPoolsByEvents(rows, 5)).toEqual([ + { poolId: "pA", count: 2 }, + { poolId: "pB", count: 1 }, + ]); + }); +}); + +describe("stickiness", () => { + it("is DAU/MAU as a rounded percentage", () => { + expect(stickiness(3, 12)).toBe(25); + expect(stickiness(1, 3)).toBe(33); + }); + it("is 0 when there are no monthly actives", () => { + expect(stickiness(0, 0)).toBe(0); + }); +}); diff --git a/lib/analytics/aggregate.ts b/lib/analytics/aggregate.ts new file mode 100644 index 0000000..cdef1f5 --- /dev/null +++ b/lib/analytics/aggregate.ts @@ -0,0 +1,117 @@ +// Pure rollups over raw analytics-event rows — no DB, no clock. Every function +// takes its `now` explicitly so the math is deterministic and unit-testable +// (lib/analytics/aggregate.test.ts). lib/analytics/queries.ts fetches the rows +// and feeds them here. Days are bucketed in UTC so boundaries are stable +// regardless of the server timezone. + +import type { AnalyticsEventType } from "@/generated/prisma/enums"; + +export interface EventRow { + type: AnalyticsEventType; + userId: string | null; + poolId: string | null; + createdAt: Date; +} + +export interface DayBucket { + date: string; // UTC YYYY-MM-DD + count: number; +} + +export interface PoolCount { + poolId: string; + count: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +// UTC calendar day key for a timestamp, e.g. "2026-06-30". +export function dayKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +// Midnight (00:00:00.000 UTC) of the day that is `n` days before `now`. +// n=0 → start of today, n=6 → start of the day six days ago. +export function startOfDayUtcDaysAgo(now: Date, n: number): Date { + const d = new Date(now); + d.setUTCHours(0, 0, 0, 0); + d.setUTCDate(d.getUTCDate() - n); + return d; +} + +// An ordered map of the last `days` UTC day-keys (oldest → newest), each seeded +// with the result of `seed()`. Insertion order is preserved by Map, so callers +// can emit a continuous, zero-filled x-axis. +function emptyDays(days: number, now: Date, seed: () => T): Map { + const out = new Map(); + for (let i = days - 1; i >= 0; i--) { + out.set(dayKey(startOfDayUtcDaysAgo(now, i)), seed()); + } + return out; +} + +// Total events per UTC day across the trailing `days` window, oldest → newest, +// zero-filled. Rows older than the window are ignored. +export function eventsPerDay(rows: EventRow[], days: number, now: Date): DayBucket[] { + const counts = emptyDays(days, now, () => 0); + for (const r of rows) { + const k = dayKey(r.createdAt); + const cur = counts.get(k); + if (cur !== undefined) counts.set(k, cur + 1); + } + return [...counts.entries()].map(([date, count]) => ({ date, count })); +} + +// Distinct active users per UTC day (a user counts once per day, on any event), +// oldest → newest, zero-filled. Events with no userId are ignored. +export function activeUsersPerDay(rows: EventRow[], days: number, now: Date): DayBucket[] { + const sets = emptyDays(days, now, () => new Set()); + for (const r of rows) { + if (!r.userId) continue; + const s = sets.get(dayKey(r.createdAt)); + if (s) s.add(r.userId); + } + return [...sets.entries()].map(([date, s]) => ({ date, count: s.size })); +} + +// Distinct users with any event in the trailing window (now - windowDays, now]. +// DAU → windowDays=1, WAU → 7, MAU → 30. +export function distinctActiveUsers(rows: EventRow[], now: Date, windowDays: number): number { + const since = new Date(now.getTime() - windowDays * DAY_MS); + const users = new Set(); + for (const r of rows) { + if (r.userId && r.createdAt > since && r.createdAt <= now) users.add(r.userId); + } + return users.size; +} + +// Total events per type, descending by count (ties broken by type name so the +// order is stable). +export function countByType(rows: EventRow[]): { type: AnalyticsEventType; count: number }[] { + const counts = new Map(); + for (const r of rows) counts.set(r.type, (counts.get(r.type) ?? 0) + 1); + return [...counts.entries()] + .map(([type, count]) => ({ type, count })) + .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)); +} + +// The most active pools by event volume, descending, capped at `limit`. Events +// with no poolId (sign-in, sign-up) are ignored. +export function topPoolsByEvents(rows: EventRow[], limit: number): PoolCount[] { + const counts = new Map(); + for (const r of rows) { + if (!r.poolId) continue; + counts.set(r.poolId, (counts.get(r.poolId) ?? 0) + 1); + } + return [...counts.entries()] + .map(([poolId, count]) => ({ poolId, count })) + .sort((a, b) => b.count - a.count || a.poolId.localeCompare(b.poolId)) + .slice(0, limit); +} + +// DAU/MAU stickiness as a whole-number percentage (0 when there are no monthly +// actives). +export function stickiness(dau: number, mau: number): number { + if (mau <= 0) return 0; + return Math.round((dau / mau) * 100); +} diff --git a/lib/analytics/events.ts b/lib/analytics/events.ts new file mode 100644 index 0000000..6d9815e --- /dev/null +++ b/lib/analytics/events.ts @@ -0,0 +1,36 @@ +// Product-analytics event logging. One best-effort write per tracked engagement +// action — mirrors notifyPool (lib/realtime/notify.ts): callers await it (so the +// row flushes), but any failure is swallowed and must never break the operation +// that triggered it. It is a single indexed INSERT placed after any surrounding +// transaction closes, so the added latency is minimal and it never holds a lock. +// The admin dashboard (app/admin/analytics) aggregates these rows; the pure +// bucketing/rollup math lives in lib/analytics/aggregate.ts. + +import { prisma } from "@/lib/db"; +import type { AnalyticsEventType } from "@/generated/prisma/enums"; + +export interface LogEventInput { + type: AnalyticsEventType; + userId?: string | null; + poolId?: string | null; + tournamentId?: string | null; + // Small context blob (e.g. { provider }, { format }, { replaced }). Omitted + // when absent so the column stays NULL rather than JSON-null. + metadata?: Record | null; +} + +export async function logEvent(input: LogEventInput): Promise { + try { + await prisma.analyticsEvent.create({ + data: { + type: input.type, + userId: input.userId ?? null, + poolId: input.poolId ?? null, + tournamentId: input.tournamentId ?? null, + metadata: input.metadata ? (input.metadata as object) : undefined, + }, + }); + } catch (err) { + console.error("logEvent failed:", err); + } +} diff --git a/lib/analytics/queries.ts b/lib/analytics/queries.ts new file mode 100644 index 0000000..9ea156a --- /dev/null +++ b/lib/analytics/queries.ts @@ -0,0 +1,90 @@ +// Admin analytics: fetch the trailing window of events once, then hand the rows +// to the pure rollups in aggregate.ts. Structural totals (users/pools/entries) +// come straight from their tables; engagement metrics come from AnalyticsEvent. +// Read by the super-admin dashboard (app/admin/analytics/page.tsx). + +import { prisma } from "@/lib/db"; +import { + eventsPerDay, + activeUsersPerDay, + distinctActiveUsers, + countByType, + topPoolsByEvents, + stickiness, + startOfDayUtcDaysAgo, + type EventRow, + type DayBucket, +} from "./aggregate"; +import type { AnalyticsEventType } from "@/generated/prisma/enums"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const TOP_POOLS = 8; + +export interface AdminAnalytics { + windowDays: number; + totals: { users: number; pools: number; entries: number; events: number }; + newUsers: { last7d: number; last30d: number }; + active: { dau: number; wau: number; mau: number; stickiness: number }; + eventsPerDay: DayBucket[]; + activeUsersPerDay: DayBucket[]; + byType: { type: AnalyticsEventType; count: number }[]; + topPools: { poolId: string; name: string; count: number }[]; +} + +export async function getAdminAnalytics( + now: Date = new Date(), + windowDays = 30, +): Promise { + const d7 = new Date(now.getTime() - 7 * DAY_MS); + const d30 = new Date(now.getTime() - 30 * DAY_MS); + // Fetch from the earliest boundary either consumer needs: the start of the + // oldest chart day (midnight, windowDays-1 ago) OR the rolling MAU window start + // (now - 30d). The latter is up to ~24h earlier, so anchoring only on the chart + // day would clip late-in-the-day actives from MAU and overstate stickiness. + const earliest = new Date( + Math.min(startOfDayUtcDaysAgo(now, windowDays - 1).getTime(), d30.getTime()), + ); + + const [users, pools, entries, events, newUsers7, newUsers30, rawRows] = await Promise.all([ + prisma.user.count(), + prisma.pool.count(), + prisma.entry.count(), + prisma.analyticsEvent.count(), + prisma.user.count({ where: { createdAt: { gte: d7 } } }), + prisma.user.count({ where: { createdAt: { gte: d30 } } }), + prisma.analyticsEvent.findMany({ + where: { createdAt: { gte: earliest } }, + select: { type: true, userId: true, poolId: true, createdAt: true }, + }), + ]); + + const rows: EventRow[] = rawRows; + + const dau = distinctActiveUsers(rows, now, 1); + const wau = distinctActiveUsers(rows, now, 7); + const mau = distinctActiveUsers(rows, now, 30); + + const top = topPoolsByEvents(rows, TOP_POOLS); + const poolRows = top.length + ? await prisma.pool.findMany({ + where: { id: { in: top.map((p) => p.poolId) } }, + select: { id: true, name: true }, + }) + : []; + const nameById = new Map(poolRows.map((p) => [p.id, p.name])); + + return { + windowDays, + totals: { users, pools, entries, events }, + newUsers: { last7d: newUsers7, last30d: newUsers30 }, + active: { dau, wau, mau, stickiness: stickiness(dau, mau) }, + eventsPerDay: eventsPerDay(rows, windowDays, now), + activeUsersPerDay: activeUsersPerDay(rows, windowDays, now), + byType: countByType(rows), + topPools: top.map((p) => ({ + poolId: p.poolId, + name: nameById.get(p.poolId) ?? "Unknown pool", + count: p.count, + })), + }; +} diff --git a/lib/pool/invites.ts b/lib/pool/invites.ts index eb0c4b2..36d170b 100644 --- a/lib/pool/invites.ts +++ b/lib/pool/invites.ts @@ -7,6 +7,7 @@ import { prisma } from "@/lib/db"; import { canAddMember, POOL_FULL_MESSAGE } from "@/lib/billing/entitlements"; import { claimEntriesForUser } from "@/lib/auth/claim"; import { generateInviteToken, isInviteValid } from "@/lib/pool/invite-token"; +import { logEvent } from "@/lib/analytics/events"; // Invites expire after two weeks by default — long enough to chase a friend, // short enough that a leaked link doesn't live forever. Pass ttlMs: null to @@ -89,6 +90,7 @@ export async function acceptInvite(input: { // Enforce the member cap atomically inside the accept transaction, serialized // by a per-pool advisory lock so concurrent invite accepts can't overshoot the // cap. Only a genuinely new member is capped. + let isNewMember = false; await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${invite.poolId}))`; const existing = await tx.membership.findUnique({ @@ -96,6 +98,7 @@ export async function acceptInvite(input: { select: { id: true }, }); if (!existing) { + isNewMember = true; const memberCount = await tx.membership.count({ where: { poolId: invite.poolId } }); if (!canAddMember(invite.pool.tier, memberCount)) throw new Error(POOL_FULL_MESSAGE); } @@ -110,6 +113,11 @@ export async function acceptInvite(input: { }); }); + // Engagement: only a genuinely new membership counts as a join. Best-effort. + if (isNewMember) { + await logEvent({ type: "POOL_JOIN", userId: input.userId, poolId: invite.poolId, metadata: { via: "invite" } }); + } + const claimed = await claimEntriesForUser(input.userId, user?.email); return { poolId: invite.poolId, joinCode: invite.pool.joinCode, claimed }; } diff --git a/lib/pool/manage.ts b/lib/pool/manage.ts index beb0c2a..5b89b7b 100644 --- a/lib/pool/manage.ts +++ b/lib/pool/manage.ts @@ -10,6 +10,7 @@ import { claimEntriesForUser } from "@/lib/auth/claim"; import { recomputePool } from "@/lib/pool/scoring"; import { notifyPool } from "@/lib/realtime/notify"; import { canAddMember, POOL_FULL_MESSAGE } from "@/lib/billing/entitlements"; +import { logEvent } from "@/lib/analytics/events"; // A unique join code, retrying on the (rare) collision against the unique index. async function allocateJoinCode(): Promise { @@ -83,6 +84,14 @@ export async function createPool(input: CreatePoolInput): Promise { select: { id: true, joinCode: true }, }); + await logEvent({ + type: "POOL_CREATE", + userId: input.userId, + poolId: pool.id, + tournamentId: tournament.id, + metadata: { format }, + }); + return pool; } @@ -123,6 +132,7 @@ export async function joinPool(input: JoinPoolInput): Promise { // pattern as recomputePool) serializes concurrent joins so the count→insert // can't overshoot the cap at the boundary. Only new members are capped — an // existing member re-joining is idempotent and never turned away, even at cap. + let isNewMember = false; await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${pool.id}))`; const existing = await tx.membership.findUnique({ @@ -130,6 +140,7 @@ export async function joinPool(input: JoinPoolInput): Promise { select: { id: true }, }); if (!existing) { + isNewMember = true; const memberCount = await tx.membership.count({ where: { poolId: pool.id } }); if (!canAddMember(pool.tier, memberCount)) throw new Error(POOL_FULL_MESSAGE); } @@ -140,6 +151,12 @@ export async function joinPool(input: JoinPoolInput): Promise { }); }); + // Engagement: only a genuinely new membership counts as a join (a re-join is + // idempotent and shouldn't inflate the metric). Best-effort. + if (isNewMember) { + await logEvent({ type: "POOL_JOIN", userId: input.userId, poolId: pool.id, metadata: { via: "code" } }); + } + // Bind any imported entries matching this account's email (also enrolls the // user in their other pools — harmless and consistent with sign-in claiming). const claimed = await claimEntriesForUser(input.userId, user?.email); diff --git a/lib/pool/submit-picks.ts b/lib/pool/submit-picks.ts index 83aea38..2792f9a 100644 --- a/lib/pool/submit-picks.ts +++ b/lib/pool/submit-picks.ts @@ -11,6 +11,7 @@ import type { Picks, Submission } from "@/lib/scoring/types"; import { emptyPicks } from "@/lib/scoring/types"; import { asAdvanceMap, type AdvanceMap } from "@/lib/pool/knockout-advance"; import type { PoolFormat } from "@/generated/prisma/enums"; +import { logEvent } from "@/lib/analytics/events"; export interface SubmitPicksInput { // The pool this bracket belongs to, or null for a standalone bracket (built @@ -46,6 +47,20 @@ export interface SubmitPicksResult { } export async function upsertUiEntry(input: SubmitPicksInput): Promise { + const result = await writeUiEntryWithRetry(input); + // Engagement: every in-app bracket save (new or edit) is an activity signal. + // Best-effort — a logging failure never breaks the submission. + await logEvent({ + type: "ENTRY_SUBMIT", + userId: input.userId, + poolId: input.poolId, + tournamentId: input.tournamentId ?? null, + metadata: { replaced: result.replaced, format: input.format }, + }); + return result; +} + +async function writeUiEntryWithRetry(input: SubmitPicksInput): Promise { try { return await writeUiEntry(input); } catch (err) { diff --git a/prisma/migrations/20260630000000_analytics_events/migration.sql b/prisma/migrations/20260630000000_analytics_events/migration.sql new file mode 100644 index 0000000..8a62e7b --- /dev/null +++ b/prisma/migrations/20260630000000_analytics_events/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "AnalyticsEventType" AS ENUM ('SIGN_IN', 'SIGN_UP', 'POOL_CREATE', 'POOL_JOIN', 'ENTRY_SUBMIT', 'CHAT_MESSAGE', 'REACTION'); + +-- CreateTable +CREATE TABLE "AnalyticsEvent" ( + "id" TEXT NOT NULL, + "type" "AnalyticsEventType" NOT NULL, + "userId" TEXT, + "poolId" TEXT, + "tournamentId" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AnalyticsEvent_createdAt_idx" ON "AnalyticsEvent"("createdAt"); + +-- CreateIndex +CREATE INDEX "AnalyticsEvent_type_createdAt_idx" ON "AnalyticsEvent"("type", "createdAt"); + +-- CreateIndex +CREATE INDEX "AnalyticsEvent_userId_createdAt_idx" ON "AnalyticsEvent"("userId", "createdAt"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4f6d368..89d966b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -731,3 +731,36 @@ model RateLimit { @@index([expiresAt]) } + +// Product-analytics event log powering the platform super-admin dashboard +// (app/admin/analytics). Append-only and audit-style: like PrizeAward/Feedback, +// the foreign ids are plain nullable strings (no relations / no cascade) so the +// table is fully additive and a later user/pool deletion never touches these +// rows. One row per tracked engagement action; writes are best-effort and must +// never block the action that triggered them (lib/analytics/events.ts), and the +// dashboard rolls these up by type and UTC day (lib/analytics/aggregate.ts). +enum AnalyticsEventType { + SIGN_IN + SIGN_UP + POOL_CREATE + POOL_JOIN + ENTRY_SUBMIT + CHAT_MESSAGE + REACTION +} + +model AnalyticsEvent { + id String @id @default(cuid()) + type AnalyticsEventType + userId String? + poolId String? + tournamentId String? + // Optional small context blob (e.g. { provider }, { format }, { replaced }). + metadata Json? + createdAt DateTime @default(now()) + + // Trend queries scan by day; the type/user composites serve breakdowns + DAU. + @@index([createdAt]) + @@index([type, createdAt]) + @@index([userId, createdAt]) +} From bfa61cb7bfdcaa176f15d990ce08d45aab57c097 Mon Sep 17 00:00:00 2001 From: dommango-sys Date: Tue, 30 Jun 2026 20:21:19 -0400 Subject: [PATCH 2/7] chore: rename analytics migration to sort after add_match_spreads --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename prisma/migrations/{20260630000000_analytics_events => 20260630120000_analytics_events}/migration.sql (100%) diff --git a/prisma/migrations/20260630000000_analytics_events/migration.sql b/prisma/migrations/20260630120000_analytics_events/migration.sql similarity index 100% rename from prisma/migrations/20260630000000_analytics_events/migration.sql rename to prisma/migrations/20260630120000_analytics_events/migration.sql From 1b41ed4dada52702e6a23e9cdf101c4c7867d2d0 Mon Sep 17 00:00:00 2001 From: dommango-sys Date: Thu, 2 Jul 2026 17:49:06 -0400 Subject: [PATCH 3/7] fix: guard R32 third-place seating integrity and orient API finals to the bracket slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The answer key stores third-place advancers as an ordered list whose order drives R32 seating (resolveR32Slots backtracks in list order). A standings re-save with the same eight teams in a different order silently re-seated the live bracket — orphaning recorded winners, breaking pair-map fixture mapping, and corrupting every seed-derived surface (this happened in prod mid-R32). - setGroupStandings now preserves the stored order when the submitted set is unchanged (mergeThirdAdvance), and rejects any seating that contradicts a recorded knockout winner (findKnockoutSeatingConflict). - setKnockoutResultLocked now orients API-reported scores/pens to the bracket slot (orientScoresToSlot) before mirroring, matching the live-row path — the FINAL row could previously flip against winnerCode whenever the provider's home differed from the bracket's home (exactly the pens ties in play now). --- lib/pool/bracket.test.ts | 87 +++++++++++++++++++++++++++++++++++++++- lib/pool/bracket.ts | 65 ++++++++++++++++++++++++++++++ lib/pool/results.ts | 36 ++++++++++++++--- lib/sports/poll.ts | 4 ++ 4 files changed, 185 insertions(+), 7 deletions(-) diff --git a/lib/pool/bracket.test.ts b/lib/pool/bracket.test.ts index f1ca34f..ee69760 100644 --- a/lib/pool/bracket.test.ts +++ b/lib/pool/bracket.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { resolveBracket, validateKnockoutWinner, buildKnockoutPairMatchNos } from "./bracket"; +import { + resolveBracket, + validateKnockoutWinner, + buildKnockoutPairMatchNos, + mergeThirdAdvance, + findKnockoutSeatingConflict, + orientScoresToSlot, +} from "./bracket"; import { resolveR32Slots } from "@/lib/scoring/resolve"; import { GROUPS, R32, R16, QF, SF, BRONZE, FINAL } from "@/lib/scoring/data"; import { emptyPicks, type Results } from "@/lib/scoring/types"; @@ -151,3 +158,81 @@ describe("buildKnockoutPairMatchNos", () => { expect([...pairs.values()]).not.toContain(QF[0].id); }); }); + +describe("mergeThirdAdvance", () => { + it("keeps the stored order when the same eight teams are re-submitted", () => { + const current = ["PAR", "SWE", "ECU", "COD", "BIH", "SEN", "ALG", "GHA"]; + const resubmitted = ["COD", "SWE", "ECU", "GHA", "BIH", "ALG", "PAR", "SEN"]; + // Same set, different order — the order drives R32 seating, so it must not move. + expect(mergeThirdAdvance(current, resubmitted)).toBe(current); + }); + + it("accepts the submitted list when the set actually changes", () => { + const current = ["PAR", "SWE", "ECU", "COD", "BIH", "SEN", "ALG", "GHA"]; + const changed = ["PAR", "SWE", "ECU", "COD", "BIH", "SEN", "ALG", "EGY"]; + expect(mergeThirdAdvance(current, changed)).toBe(changed); + expect(mergeThirdAdvance([], changed)).toBe(changed); + }); +}); + +describe("findKnockoutSeatingConflict", () => { + it("is null when every recorded winner sits in its resolved matchup", () => { + const results = chalkStandings(); + const bracket = resolveBracket(results); + const knockout = { 73: bracket[73].home!, 74: bracket[74].away! }; + expect(findKnockoutSeatingConflict({ ...results, knockout })).toBeNull(); + }); + + it("flags a thirdAdvance reorder that unseats a recorded winner", () => { + const results = chalkStandings(); + // Record each third-place side's occupant as its match's winner… + const bracket = resolveBracket(results); + const thirdSlots = R32.filter((m) => "third" in m.b); + const knockout = Object.fromEntries(thirdSlots.map((m) => [m.id, bracket[m.id].away!])); + expect(findKnockoutSeatingConflict({ ...results, knockout })).toBeNull(); + // …then rotate the thirdAdvance order: the backtracker re-seats the slots, so + // at least one recorded winner no longer sits in its resolved matchup. + const rotated = [...results.thirdAdvance.slice(1), results.thirdAdvance[0]]; + const conflict = findKnockoutSeatingConflict({ ...results, thirdAdvance: rotated, knockout }); + expect(conflict).toMatch(/not in its resolved matchup/); + }); + + it("ignores matches whose slots are not resolvable yet", () => { + // A recorded R16 winner with no R32 winners recorded — feeders unresolved, no conflict. + const results = { ...chalkStandings(), knockout: { [R16[0].id]: GROUPS.A[0] } }; + expect(findKnockoutSeatingConflict(results)).toBeNull(); + }); +}); + +describe("orientScoresToSlot", () => { + const slot = { home: "MEX", away: "USA" }; + const scores = { homeScore: 1, awayScore: 2, homePens: 5, awayPens: 4 }; + + it("passes through when the API home is the bracket home", () => { + expect(orientScoresToSlot(slot, { ...scores, apiHomeCode: "MEX", apiAwayCode: "USA" })).toEqual( + scores, + ); + }); + + it("swaps scores AND pens when the API home is the bracket away", () => { + expect(orientScoresToSlot(slot, { ...scores, apiHomeCode: "USA", apiAwayCode: "MEX" })).toEqual( + { homeScore: 2, awayScore: 1, homePens: 4, awayPens: 5 }, + ); + }); + + it("passes through untouched with no API codes (manual entry) or an unseated slot", () => { + expect(orientScoresToSlot(slot, scores)).toEqual(scores); + expect(orientScoresToSlot(undefined, { ...scores, apiHomeCode: "USA" })).toEqual(scores); + expect(orientScoresToSlot({ home: null, away: null }, { ...scores, apiHomeCode: "USA" })).toEqual( + scores, + ); + // API home matches neither seated team — don't guess. + expect(orientScoresToSlot(slot, { ...scores, apiHomeCode: "BRA" })).toEqual(scores); + }); + + it("normalizes missing fields to null", () => { + expect(orientScoresToSlot(slot, { apiHomeCode: "USA", apiAwayCode: "MEX", homeScore: 3 })).toEqual( + { homeScore: null, awayScore: 3, homePens: null, awayPens: null }, + ); + }); +}); diff --git a/lib/pool/bracket.ts b/lib/pool/bracket.ts index 46d90f5..1501cc8 100644 --- a/lib/pool/bracket.ts +++ b/lib/pool/bracket.ts @@ -77,6 +77,71 @@ export function buildKnockoutPairMatchNos(results: Results): Map const KNOCKOUT_RANGE = (n: number) => n >= 73 && n <= 104; +// The ORDER of results.thirdAdvance is load-bearing: resolveR32Slots seats +// third-place teams by backtracking in list order, so two different orderings of +// the same eight teams can seat different R32 matchups. When an admin re-saves +// standings with the same eight advancers, keep the stored order — otherwise a +// form whose fields happen to iterate differently silently re-seats the live +// bracket (which is exactly how prod's R32 seating got scrambled mid-round). +export function mergeThirdAdvance( + current: TeamCode[], + submitted: TeamCode[], +): TeamCode[] { + const sameSet = + current.length === submitted.length && + [...current].sort().join(",") === [...submitted].sort().join(","); + return sameSet ? current : submitted; +} + +// Reject an answer key whose resolved seating contradicts a winner it already +// records: if match N's winner is no longer one of match N's two seated teams, +// the seating (not the winner) is wrong — surfaced before the write so a +// thirdAdvance reorder can't orphan recorded results and break fixture mapping. +export function findKnockoutSeatingConflict(results: Results): string | null { + const bracket = resolveBracket(results); + for (const [no, winner] of Object.entries(results.knockout || {})) { + if (!winner) continue; + const m = bracket[Number(no)]; + if (m?.home && m?.away && winner !== m.home && winner !== m.away) { + return `Match ${no}'s recorded winner ${winner} is not in its resolved matchup (${m.home} vs ${m.away}) — third-place order is inconsistent with recorded results`; + } + } + return null; +} + +// Orient an API-reported scoreline (home/away as the provider frames the fixture) +// to the bracket slot's home/away, so the FINAL Result row reads the same way as +// the live row (which already orients) and never contradicts winnerCode. When the +// API home code matches neither seated team (or no codes are provided) the input +// is returned unchanged — the manual-entry path passes no API codes. +export function orientScoresToSlot( + slot: { home: TeamCode | null; away: TeamCode | null } | undefined, + input: { + homeScore?: number | null; + awayScore?: number | null; + homePens?: number | null; + awayPens?: number | null; + apiHomeCode?: string | null; + apiAwayCode?: string | null; + }, +): { homeScore: number | null; awayScore: number | null; homePens: number | null; awayPens: number | null } { + const asIs = { + homeScore: input.homeScore ?? null, + awayScore: input.awayScore ?? null, + homePens: input.homePens ?? null, + awayPens: input.awayPens ?? null, + }; + if (!input.apiHomeCode || !slot?.home || !slot?.away) return asIs; + if (input.apiHomeCode === slot.home) return asIs; + if (input.apiHomeCode !== slot.away) return asIs; // unrecognized orientation — don't guess + return { + homeScore: asIs.awayScore, + awayScore: asIs.homeScore, + homePens: asIs.awayPens, + awayPens: asIs.homePens, + }; +} + export interface ValidationResult { ok: boolean; reason?: string; diff --git a/lib/pool/results.ts b/lib/pool/results.ts index 1aa4c47..45cf16b 100644 --- a/lib/pool/results.ts +++ b/lib/pool/results.ts @@ -10,7 +10,13 @@ import { asResults, recomputePool, recomputeStandalone } from "@/lib/pool/scorin import { notifyPool } from "@/lib/realtime/notify"; import { sendPushToPool } from "@/lib/push/send"; import type { ApnsPayload } from "@/lib/push/apns"; -import { resolveBracket, validateKnockoutWinner } from "@/lib/pool/bracket"; +import { + resolveBracket, + validateKnockoutWinner, + mergeThirdAdvance, + findKnockoutSeatingConflict, + orientScoresToSlot, +} from "@/lib/pool/bracket"; import { findStandingsConflict } from "@/lib/pool/standings"; import { promoteCompletedGroups } from "@/lib/pool/group-promote"; import type { GroupResultRow } from "@/lib/pool/group-table"; @@ -55,6 +61,13 @@ export interface KnockoutInput { awayScore?: number | null; homePens?: number | null; // shootout score when the tie went to penalties awayPens?: number | null; + // How the API frames the fixture's home/away. When set, the scores/pens above + // are in THIS orientation and get re-oriented to the bracket slot before the + // Result mirror (the live display path already orients; without this the FINAL + // row flips whenever the provider's home differs from the bracket's home). + // Manual entry omits these — admin scores are already bracket-oriented. + apiHomeCode?: string | null; + apiAwayCode?: string | null; final?: boolean; // default true; false marks the match LIVE rather than FINAL source?: "API" | "MANUAL"; // default MANUAL } @@ -90,13 +103,14 @@ async function setKnockoutResultLocked( await writeAnswerKey(tournamentId, next, tx); const slot = resolveBracket(next)[matchNo]; + const oriented = orientScoresToSlot(slot, input); await mirrorResultRow(tx, tournamentId, matchNo, { homeTeamCode: slot?.home ?? null, awayTeamCode: slot?.away ?? null, - homeScore: input.homeScore ?? null, - awayScore: input.awayScore ?? null, - homePens: input.homePens ?? null, - awayPens: input.awayPens ?? null, + homeScore: oriented.homeScore, + awayScore: oriented.awayScore, + homePens: oriented.homePens, + awayPens: oriented.awayPens, winnerCode: winner, final: input.final ?? true, source: input.source ?? "MANUAL", @@ -346,9 +360,13 @@ export async function setGroupStandings( let thirdAdvance = current.thirdAdvance; if (input.thirdAdvance) { - thirdAdvance = input.thirdAdvance + const cleaned = input.thirdAdvance .map((c) => (c || "").toUpperCase()) .filter((c) => c && TEAMS[c]); + // Same eight advancers → keep the stored order: the order drives R32 + // seating (resolveR32Slots backtracks in list order), so a re-save of the + // same set must never re-seat the live bracket. + thirdAdvance = mergeThirdAdvance(current.thirdAdvance ?? [], cleaned); } const next: Results = { @@ -363,6 +381,12 @@ export async function setGroupStandings( const conflict = findStandingsConflict(next); if (conflict) throw new Error(conflict); + // Reject a standings edit whose re-seated bracket would orphan a recorded + // knockout winner — fixture mapping, daily scoring and every seed-derived + // surface read the seating, so an inconsistent reorder must not be written. + const seating = findKnockoutSeatingConflict(next); + if (seating) throw new Error(seating); + await writeAnswerKey(tournamentId, next, tx); return next; }); diff --git a/lib/sports/poll.ts b/lib/sports/poll.ts index f951552..4453d0c 100644 --- a/lib/sports/poll.ts +++ b/lib/sports/poll.ts @@ -292,6 +292,10 @@ export async function pollScores(): Promise { awayScore: f.awayGoals, homePens: f.homePens, awayPens: f.awayPens, + // API orientation of the scores above — the mirror re-orients them to the + // bracket slot so the FINAL row can't flip against the live row. + apiHomeCode: homeCode ?? null, + apiAwayCode: awayCode ?? null, final: true, }); if (didApply) { From 85b825aa6d695b4f31dc0e8a2c76ad01b5532f32 Mon Sep 17 00:00:00 2001 From: dommango-sys Date: Thu, 2 Jul 2026 17:49:18 -0400 Subject: [PATCH 4/7] fix: stop decided matches double-counting or pinning as live on the boards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - liveLeaders now skips matches the answer key already decided, so the projected-points layer can't pay a match twice during the whistle-to-feed reconcile window (the official total already contains it). - getLiveMatches filters out answer-key-decided matches, and statusOf treats a known winner as FINAL over a Result row stuck at LIVE — a missed poll could previously pin a finished match on the live card forever. --- lib/pool/match-center.test.ts | 16 ++++++++++++++-- lib/pool/match-center.ts | 11 +++++++---- lib/pool/projected.test.ts | 14 ++++++++++++++ lib/pool/projected.ts | 11 +++++++++-- lib/pool/queries.ts | 23 ++++++++++++++++++++--- 5 files changed, 64 insertions(+), 11 deletions(-) diff --git a/lib/pool/match-center.test.ts b/lib/pool/match-center.test.ts index 1f574cd..efe38fd 100644 --- a/lib/pool/match-center.test.ts +++ b/lib/pool/match-center.test.ts @@ -41,13 +41,25 @@ describe("buildMatchCenter", () => { expect(g.matches[0].away.code).toBeNull(); }); - it("derives status: Result status wins, else winner => FINAL, else SCHEDULED", () => { + it("derives status: a known winner => FINAL (even over a stale LIVE row), else Result status", () => { const [sec] = buildMatchCenter([ + // A recorded winner with a Result stuck at LIVE (missed poll / answer key + // entered first) is a decided match — it must not render live forever. m({ matchNo: 1, roundCode: "GROUP", resultStatus: "LIVE", winnerCode: "MEX" }), m({ matchNo: 2, roundCode: "GROUP", winnerCode: "CAN" }), m({ matchNo: 3, roundCode: "GROUP" }), + // Genuinely live: LIVE row, no winner anywhere yet. + m({ matchNo: 4, roundCode: "GROUP", resultStatus: "LIVE" }), + // Drawn group match: no winner, but the Result row says FINAL. + m({ matchNo: 5, roundCode: "GROUP", resultStatus: "FINAL" }), + ]); + expect(sec.matches.map((r) => r.status)).toEqual([ + "FINAL", + "FINAL", + "SCHEDULED", + "LIVE", + "FINAL", ]); - expect(sec.matches.map((r) => r.status)).toEqual(["LIVE", "FINAL", "SCHEDULED"]); }); it("serializes scheduledAt to ISO", () => { diff --git a/lib/pool/match-center.ts b/lib/pool/match-center.ts index cfb60f0..13de3b8 100644 --- a/lib/pool/match-center.ts +++ b/lib/pool/match-center.ts @@ -116,11 +116,14 @@ export interface MatchCenterSection { defaultOpen?: boolean; // when collapsible, whether it starts expanded } -// A match is FINAL when the Result says so, or (no Result yet) when the answer -// key already records a winner. LIVE only ever comes from a live Result feed. +// A match is FINAL when a winner is known (Result row or answer key) — a known +// winner overrides a Result stuck at LIVE, so a missed poll can't pin a decided +// match as "live" forever. Otherwise the Result feed's status is authoritative. +// (A drawn group match has no winnerCode but its Result says FINAL — covered by +// the resultStatus fallback.) function statusOf(m: MatchInput): MatchStatus { - if (m.resultStatus) return m.resultStatus; - return m.winnerCode ? "FINAL" : "SCHEDULED"; + if (m.winnerCode) return "FINAL"; + return m.resultStatus ?? "SCHEDULED"; } function buildRow( diff --git a/lib/pool/projected.test.ts b/lib/pool/projected.test.ts index 8b89d3a..b3542a0 100644 --- a/lib/pool/projected.test.ts +++ b/lib/pool/projected.test.ts @@ -66,3 +66,17 @@ describe("projectedLivePoints", () => { expect(out.get("e1")).toBe(10); }); }); + +describe("liveLeaders — decided matches", () => { + it("skips a LIVE row whose match the answer key already decided", () => { + // The scored totals already contain a decided match; projecting it again from + // a Result row the feed hasn't flipped to FINAL would pay it twice. + expect(liveLeaders([row({})], new Set([73]))).toEqual([]); + }); + + it("still projects live matches the answer key has not decided", () => { + expect(liveLeaders([row({})], new Set([74]))).toEqual([ + { matchNo: 73, leadingCode: "BRA" }, + ]); + }); +}); diff --git a/lib/pool/projected.ts b/lib/pool/projected.ts index 454053e..bfd2eff 100644 --- a/lib/pool/projected.ts +++ b/lib/pool/projected.ts @@ -24,10 +24,17 @@ const isKnockout = (n: number) => n >= 73 && n <= 104; // The currently-leading side of each live knockout match. Ties and unknown // teams project nothing — a draw resolves on penalties, which we can't guess. -export function liveLeaders(rows: LiveResultRow[]): LiveLeader[] { +// `decided` is the set of match numbers already recorded in the answer key: a +// decided match is in every entry's OFFICIAL total, so projecting it again from +// a Result row the feed hasn't flipped to FINAL yet would pay the same match +// twice on the live board during the whistle→feed-reconcile window. +export function liveLeaders( + rows: LiveResultRow[], + decided: ReadonlySet = new Set(), +): LiveLeader[] { const out: LiveLeader[] = []; for (const r of rows) { - if (r.status !== "LIVE" || !isKnockout(r.matchNo)) continue; + if (r.status !== "LIVE" || !isKnockout(r.matchNo) || decided.has(r.matchNo)) continue; if (r.homeScore == null || r.awayScore == null || r.homeScore === r.awayScore) continue; const leadingCode = r.homeScore > r.awayScore ? r.homeTeamCode : r.awayTeamCode; if (!leadingCode) continue; diff --git a/lib/pool/queries.ts b/lib/pool/queries.ts index 1046c26..2a32e56 100644 --- a/lib/pool/queries.ts +++ b/lib/pool/queries.ts @@ -424,7 +424,7 @@ async function withProjectedPoints( format: PoolFormat, rows: LeaderboardRow[], ): Promise { - const [liveRows, groupPoints] = await Promise.all([ + const [liveRows, groupPoints, tournament] = await Promise.all([ prisma.result.findMany({ where: { status: "LIVE", match: { tournamentId } }, select: { @@ -437,10 +437,20 @@ async function withProjectedPoints( }, }), provisionalGroupPoints(poolId, tournamentId, scoringConfig), + prisma.tournament.findUnique({ + where: { id: tournamentId }, + select: { officialResults: true }, + }), ]); + // Matches the answer key already decided are in the official totals; exclude + // them from projection so a Result row lagging at LIVE can't pay them twice. + const decided = new Set( + Object.keys(asResults(tournament?.officialResults).knockout || {}).map(Number), + ); const leaders = liveLeaders( liveRows.map((r) => ({ ...r, matchNo: r.match.matchNo })), + decided, ); if (leaders.length === 0 && groupPoints.size === 0) return rows; @@ -956,10 +966,17 @@ export async function getLiveMatches( }); if (matches.length === 0) return []; - const resolved = resolveBracket(asResults(pool.tournament.officialResults)); + const answerKey = asResults(pool.tournament.officialResults); + // A match whose winner is already in the answer key is over regardless of what + // the feed row says — a poll that never flipped LIVE→FINAL must not pin a + // decided match on the "live now" card forever. + const live = matches.filter((m) => !answerKey.knockout?.[m.matchNo]); + if (live.length === 0) return []; + + const resolved = resolveBracket(answerKey); const yourPicks = await getEntryKnockoutPicks(poolId, userId); - const inputs = matches.map((m) => toMatchInput(m, resolved)); + const inputs = live.map((m) => toMatchInput(m, resolved)); // buildMatchCenter groups + orders by round; flatten back to a single list. return buildMatchCenter(inputs, yourPicks).flatMap((s) => s.matches); From cda733c0265d9532a314d6ee53c4705c72510444 Mon Sep 17 00:00:00 2001 From: dommango-sys Date: Thu, 2 Jul 2026 17:49:18 -0400 Subject: [PATCH 5/7] fix: games reach COMPLETE and stop advertising ended windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveGamePhase gains the COMPLETE transition (last kickoff + 6h settle): MD3 was reported LOCKED_LIVE forever, keeping a 'Live now' slide on the home carousel five days after the game ended; knockout/full-bracket got the same treatment for after the final. - isEarlyBuilderOpen now closes at the R32 kickoff instead of staying true for the rest of the tournament. - md3DateRange (and gameStateLine's day labels) are framed in DISPLAY_TZ: the UTC framing printed 'June 24–28' beside catalog copy saying '(June 24–27)'. --- lib/pool/games.test.ts | 32 ++++++++++++++- lib/pool/games.ts | 75 +++++++++++++++++++++++++++--------- lib/pool/hero-slides.test.ts | 11 +++--- lib/pool/knockout.test.ts | 13 +++++++ lib/pool/knockout.ts | 12 ++++-- 5 files changed, 116 insertions(+), 27 deletions(-) diff --git a/lib/pool/games.test.ts b/lib/pool/games.test.ts index 6a9598d..95e5da5 100644 --- a/lib/pool/games.test.ts +++ b/lib/pool/games.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { GAME_CATALOG, resolveGamePhase, featuredGame, prizeTeaser } from "./games"; +import { GAME_CATALOG, resolveGamePhase, featuredGame, prizeTeaser, md3DateRange } from "./games"; import type { PoolFormat } from "@/lib/pool/manage"; // Fixed schedule anchors mirrored from lib/scoring/schedule + lib/pool/knockout. @@ -60,6 +60,20 @@ describe("resolveGamePhase — MD3", () => { expect(s.joinable).toBe(false); expect(s.creatable).toBe(false); }); + it("is COMPLETE once the last fixture has settled (never 'live' forever)", () => { + // Last kickoff 02:00 Jun 28 + the 6h settle window → complete from 08:00. + expect(resolveGamePhase("MATCH_DAY_3_PICKEM", at("2026-06-28T07:59:00Z")).phase).toBe( + "LOCKED_LIVE", + ); + expect(resolveGamePhase("MATCH_DAY_3_PICKEM", at("2026-06-28T08:00:00Z")).phase).toBe( + "COMPLETE", + ); + // Mid-knockout (Jul 2, R32 in progress) MD3 is long over — this exact instant + // used to report LOCKED_LIVE and kept a "Live now" slide on the home carousel. + expect(resolveGamePhase("MATCH_DAY_3_PICKEM", at("2026-07-02T12:00:00Z")).phase).toBe( + "COMPLETE", + ); + }); }); describe("resolveGamePhase — KNOCKOUT", () => { @@ -74,6 +88,10 @@ describe("resolveGamePhase — KNOCKOUT", () => { it("is LOCKED_LIVE at and after the R32 kickoff", () => { expect(resolveGamePhase("KNOCKOUT", at(KO_LOCK)).phase).toBe("LOCKED_LIVE"); }); + it("stays LOCKED_LIVE through the knockout rounds, COMPLETE after the final settles", () => { + expect(resolveGamePhase("KNOCKOUT", at("2026-07-02T12:00:00Z")).phase).toBe("LOCKED_LIVE"); + expect(resolveGamePhase("KNOCKOUT", at("2026-07-21T00:00:00Z")).phase).toBe("COMPLETE"); + }); }); describe("resolveGamePhase — FULL_BRACKET", () => { @@ -82,6 +100,18 @@ describe("resolveGamePhase — FULL_BRACKET", () => { expect(resolveGamePhase("FULL_BRACKET", at(FULL_START)).phase).toBe("LOCKED_LIVE"); expect(resolveGamePhase("FULL_BRACKET", at("2026-06-22T00:00:00Z")).creatable).toBe(false); }); + it("is COMPLETE after the final settles", () => { + expect(resolveGamePhase("FULL_BRACKET", at("2026-07-21T00:00:00Z")).phase).toBe("COMPLETE"); + }); +}); + +describe("md3DateRange", () => { + it("frames the span in the display timezone (Eastern), not UTC", () => { + // Last kickoff is 02:00Z Jun 28 = 10:00 PM ET Jun 27 — the UTC framing this + // replaced printed "June 24–28", contradicting the catalog's "(June 24–27)". + expect(md3DateRange()).toBe("June 24–27"); + expect(GAME_CATALOG.MATCH_DAY_3_PICKEM.tagline).toContain(md3DateRange()); + }); }); describe("featuredGame across the Jun 22→28 window", () => { diff --git a/lib/pool/games.ts b/lib/pool/games.ts index 0878f71..429ca88 100644 --- a/lib/pool/games.ts +++ b/lib/pool/games.ts @@ -11,6 +11,7 @@ import type { PoolFormat } from "@/lib/pool/manage"; import { KNOCKOUT_PICKS_OPEN_UTC } from "@/lib/pool/knockout"; import { firstMd3Kickoff, lastMd3Kickoff } from "@/lib/pool/match-day-3"; import { kickoffFor } from "@/lib/scoring/schedule"; +import { DISPLAY_TZ } from "@/lib/tz"; import { PRIZES, isChallengeFormat } from "@/lib/challenge/prizes-config"; import { formatPrize } from "@/lib/challenge/format-prize"; @@ -95,6 +96,24 @@ const KNOCKOUT_OPEN = new Date(KNOCKOUT_PICKS_OPEN_UTC); const KNOCKOUT_LOCK = kickoffFor(73) ?? new Date(KNOCKOUT_PICKS_OPEN_UTC); const FULL_START = kickoffFor(1) ?? new Date("2026-06-11T19:00:00Z"); +// A game is COMPLETE a settle-window after its last kickoff (match over, results +// propagated) — without this every "live" phase lasted forever, so surfaces kept +// advertising a finished game as "Live now". 6h ≈ match length + ET/pens + feed lag. +const SETTLE_MS = 6 * 3_600_000; +const MD3_COMPLETE = new Date(MD3_LAST_LOCK.getTime() + SETTLE_MS); +const FINAL_KICKOFF = kickoffFor(104); +const TOURNAMENT_COMPLETE = FINAL_KICKOFF + ? new Date(FINAL_KICKOFF.getTime() + SETTLE_MS) + : null; + +const COMPLETE_STATE: GameState = { + phase: "COMPLETE", + label: "Complete", + deadline: null, + creatable: false, + joinable: false, +}; + // Pure, time-derived phase for a game. No DB — every boundary is a fixed instant. export function resolveGamePhase(format: PoolFormat, now: Date = new Date()): GameState { const t = now.getTime(); @@ -118,13 +137,16 @@ export function resolveGamePhase(format: PoolFormat, now: Date = new Date()): Ga joinable: true, }; } - return { - phase: "LOCKED_LIVE", - label: "Locked · live", - deadline: null, - creatable: false, - joinable: false, - }; + if (t < MD3_COMPLETE.getTime()) { + return { + phase: "LOCKED_LIVE", + label: "Locked · live", + deadline: null, + creatable: false, + joinable: false, + }; + } + return COMPLETE_STATE; } if (format === "KNOCKOUT") { @@ -148,6 +170,9 @@ export function resolveGamePhase(format: PoolFormat, now: Date = new Date()): Ga joinable: true, }; } + if (TOURNAMENT_COMPLETE && t >= TOURNAMENT_COMPLETE.getTime()) { + return COMPLETE_STATE; + } return { phase: "LOCKED_LIVE", label: "Locked · live", @@ -169,6 +194,9 @@ export function resolveGamePhase(format: PoolFormat, now: Date = new Date()): Ga joinable: true, }; } + if (TOURNAMENT_COMPLETE && t >= TOURNAMENT_COMPLETE.getTime()) { + return COMPLETE_STATE; + } return { phase: "LOCKED_LIVE", label: "Locked · live", @@ -205,27 +233,38 @@ export function prizeTeaser(format: PoolFormat): string | null { const MONTH_DAY = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", - timeZone: "UTC", + timeZone: DISPLAY_TZ, }); function dayLabel(d: Date): string { return MONTH_DAY.format(d); // e.g. "June 24" } +// A kickoff's calendar month/day in the app's display timezone (Eastern). The +// late fixtures kick off past midnight UTC, so a UTC framing put the last MD3 +// match on "June 28" when everyone experiences it as the June 27 night game. +function monthDayInDisplayTz(d: Date): { month: string; day: number } { + const parts = MONTH_DAY.formatToParts(d); + return { + month: parts.find((p) => p.type === "month")?.value ?? "", + day: Number(parts.find((p) => p.type === "day")?.value ?? 0), + }; +} + // The calendar span of the Match Day Pickem fixtures (first → last kickoff), for -// copy that tells players which games they're predicting and when. UTC-framed to -// match the rest of this module's date labels (gameStateLine). Same-month spans -// collapse the second month, e.g. "June 24–27"; cross-month stays "June 24 – July 2". +// copy that tells players which games they're predicting and when. Framed in the +// display timezone like every other match label. Same-month spans collapse the +// second month, e.g. "June 24–27"; cross-month stays "June 24 – July 2". export function md3DateRange(): string { - const first = firstMd3Kickoff(); - const last = lastMd3Kickoff(); - if (first.getUTCMonth() === last.getUTCMonth() && first.getUTCDate() === last.getUTCDate()) { - return dayLabel(first); + const first = monthDayInDisplayTz(firstMd3Kickoff()); + const last = monthDayInDisplayTz(lastMd3Kickoff()); + if (first.month === last.month && first.day === last.day) { + return `${first.month} ${first.day}`; } - if (first.getUTCMonth() === last.getUTCMonth()) { - return `${dayLabel(first)}–${last.getUTCDate()}`; + if (first.month === last.month) { + return `${first.month} ${first.day}–${last.day}`; } - return `${dayLabel(first)} – ${dayLabel(last)}`; + return `${first.month} ${first.day} – ${last.month} ${last.day}`; } // A short, friendly state line for a game card / badge, derived from the phase. diff --git a/lib/pool/hero-slides.test.ts b/lib/pool/hero-slides.test.ts index d0c126a..38c0fa3 100644 --- a/lib/pool/hero-slides.test.ts +++ b/lib/pool/hero-slides.test.ts @@ -20,14 +20,15 @@ describe("availableHeroSlides", () => { expect(slides.map((s) => s.format)).toEqual(["KNOCKOUT", "MATCH_DAY_3_PICKEM"]); }); - it("shows both live games (knockout first) once both are locked", () => { - // After the Round of 32 kickoff (knockout lock) — both games are live. The - // carousel keeps surfacing them, current stage (knockout) first, both "live". + it("drops MD3 once complete: only the knockout slide survives the R32 kickoff", () => { + // After the Round of 32 kickoff (knockout lock) MD3 finished the night + // before — advertising it as "Live now" was the bug. Only the genuinely + // live game keeps a slide. const r32 = kickoffFor(73); expect(r32).not.toBeNull(); const now = new Date(r32!.getTime() + 1000); const slides = availableHeroSlides(now); - expect(slides.map((s) => s.format)).toEqual(["KNOCKOUT", "MATCH_DAY_3_PICKEM"]); - expect(slides.every((s) => s.stateLine.toLowerCase().includes("live"))).toBe(true); + expect(slides.map((s) => s.format)).toEqual(["KNOCKOUT"]); + expect(slides[0].stateLine.toLowerCase()).toContain("live"); }); }); diff --git a/lib/pool/knockout.test.ts b/lib/pool/knockout.test.ts index 78f1249..6736865 100644 --- a/lib/pool/knockout.test.ts +++ b/lib/pool/knockout.test.ts @@ -3,6 +3,7 @@ import { knockoutR32Seed, isKnockoutFieldSet, isKnockoutLocked, + isEarlyBuilderOpen, knockoutOnlyPicks, hasConcreteR32Slots, knockoutOpenState, @@ -126,3 +127,15 @@ describe("isKnockoutLocked", () => { expect(isKnockoutLocked(null, true)).toBe(true); }); }); + +describe("isEarlyBuilderOpen", () => { + it("opens at the early-build instant and closes at the R32 kickoff", () => { + expect(isEarlyBuilderOpen(new Date("2026-06-17T23:59:59Z"))).toBe(false); + expect(isEarlyBuilderOpen(new Date("2026-06-18T00:00:00Z"))).toBe(true); + expect(isEarlyBuilderOpen(new Date("2026-06-27T12:00:00Z"))).toBe(true); + // At/after the R32 kickoff the bracket is locked — "build early" must never + // be advertised again (it used to stay true for the rest of the tournament). + expect(isEarlyBuilderOpen(new Date("2026-06-28T19:00:00Z"))).toBe(false); + expect(isEarlyBuilderOpen(new Date("2026-07-02T12:00:00Z"))).toBe(false); + }); +}); diff --git a/lib/pool/knockout.ts b/lib/pool/knockout.ts index 203bd04..5411eda 100644 --- a/lib/pool/knockout.ts +++ b/lib/pool/knockout.ts @@ -8,6 +8,7 @@ import { GROUPS, R32 } from "@/lib/scoring/data"; import { resolveR32Slots, type ResolvedR32 } from "@/lib/scoring/resolve"; import { emptyPicks, type Picks, type Results } from "@/lib/scoring/types"; +import { kickoffFor } from "@/lib/scoring/schedule"; const THIRDS_NEEDED = 8; @@ -25,10 +26,15 @@ export const KNOCKOUT_PICKS_OPEN_UTC = "2026-06-28T02:00:00Z"; // (the official open) and from scoring, which still key off the real answer key. export const EARLY_BUILDER_OPEN_UTC = "2026-06-18T00:00:00Z"; -// True once early projected-fill should be available (independent of the official -// open). Time-gated so a pre-tournament visitor doesn't see a bracket of pure TBDs. +// True while early projected-fill should be available (independent of the official +// open). Time-gated on both ends: opens once standings give a meaningful projection +// (not a bracket of pure TBDs), and closes at the R32 kickoff — after the lock the +// builder is read-only, so "early build" must never be advertised again. export function isEarlyBuilderOpen(now: Date = new Date()): boolean { - return now.getTime() >= new Date(EARLY_BUILDER_OPEN_UTC).getTime(); + const t = now.getTime(); + if (t < new Date(EARLY_BUILDER_OPEN_UTC).getTime()) return false; + const lock = kickoffFor(73); + return !lock || t < lock.getTime(); } // The official Round-of-32 matchups (the 32 qualifiers) from the answer key. From 99f1a28661dd5ac0cad1cdbf15e85ca9da5f1032 Mon Sep 17 00:00:00 2001 From: dommango-sys Date: Thu, 2 Jul 2026 17:49:37 -0400 Subject: [PATCH 6/7] fix: abuse and ops hardening across rate limits, claim cap, odds quota, cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Feedback keyed its anonymous rate limit on the leftmost X-Forwarded-For hop (client-spoofable → unlimited 3MB screenshot submissions). Shared clientIpFromForwardedFor now takes the edge-appended rightmost hop; the auth route reuses it. - Giphy proxy gains a per-user rate limit (shared API key quota). - claimEntriesForUser takes the same per-pool advisory lock as joinPool/acceptInvite, closing the count→create race past the FREE member cap on the primary enrollment path. - pollOddsExtras gains a DB-backed 20h throttle (extrasPollDue): the cron's in-memory daily bucket resets on every restart/redeploy, re-spending 4+ metered credits of a 500/mo quota per restart. - cron.mjs fetches time out at 30s so a draining web service can't stall the sequential job loop (and live-score polling) for ~5 minutes. - CRON_SECRET requires 16+ chars (guards result-mutating routes; prod is 48); CI/dev example values updated to match. --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 +- app/api/auth/[...nextauth]/route.ts | 16 ++++------------ app/api/feedback/route.ts | 5 ++++- app/api/giphy/search/route.ts | 6 ++++++ lib/auth/claim.ts | 7 +++++-- lib/env.ts | 5 ++++- lib/odds/extras.ts | 17 +++++++++++++++++ lib/odds/schedule.test.ts | 13 +++++++++++++ lib/odds/schedule.ts | 14 ++++++++++++++ lib/rate-limit-core.ts | 12 ++++++++++++ lib/rate-limit.test.ts | 17 ++++++++++++++++- scripts/cron.mjs | 6 ++++++ 13 files changed, 103 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b79019..58a71fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: # Build-time only — env.ts validates presence; no real services are reached. DATABASE_URL: postgresql://ci:ci@localhost:5432/ci AUTH_SECRET: ci-only-secret-at-least-32-characters-long - CRON_SECRET: ci-cron-secret + CRON_SECRET: ci-cron-secret-at-least-16-chars APP_BASE_URL: http://localhost:3000 steps: - uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 78e9e29..727c178 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Postgres `bracketeer` DB (e.g. a Docker `postgres` container on `localhost:5432` ```bash # Inline env prefix used throughout (DB + the two required secrets): -ENV='DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bracketeer AUTH_SECRET=dev-only-secret-at-least-32-characters-long CRON_SECRET=dev-cron-secret APP_BASE_URL=http://localhost:3000' +ENV='DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bracketeer AUTH_SECRET=dev-only-secret-at-least-32-characters-long CRON_SECRET=dev-cron-secret-16-chars APP_BASE_URL=http://localhost:3000' env $ENV npm run dev # dev server (http://localhost:3000) env $ENV npm run build # production build (compiles/validates every route) diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 285fe74..0a52c1d 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from "next/server"; import { handlers } from "@/auth"; import { rateLimit } from "@/lib/rate-limit"; +import { clientIpFromForwardedFor } from "@/lib/rate-limit-core"; import { apiError } from "@/lib/api"; // Auth.js v5 mounts both verbs from a single handlers object. GET (session/CSRF @@ -10,19 +11,10 @@ import { apiError } from "@/lib/api"; // a normal multi-step sign-in. export const { GET } = handlers; -// Use the RIGHTMOST x-forwarded-for hop — the one the platform edge (Railway) -// appends — not the leftmost, which is client-supplied and trivially spoofed. With -// a single hop the two coincide. Best-effort: a misconfigured proxy weakens this, -// but it's a soft brute-force guard, not an auth control. -function clientIp(req: NextRequest): string { - const xff = req.headers.get("x-forwarded-for"); - if (!xff) return "anon"; - const hops = xff.split(",").map((h) => h.trim()).filter(Boolean); - return hops.length ? hops[hops.length - 1] : "anon"; -} - export async function POST(req: NextRequest): Promise { - if (!(await rateLimit(`auth-post:${clientIp(req)}`, 20, 60_000)).ok) { + // Rightmost-hop IP keying — see clientIpFromForwardedFor for why leftmost is unsafe. + const ip = clientIpFromForwardedFor(req.headers.get("x-forwarded-for")); + if (!(await rateLimit(`auth-post:${ip}`, 20, 60_000)).ok) { return apiError("Too many attempts — wait a minute and try again.", 429); } return handlers.POST(req); diff --git a/app/api/feedback/route.ts b/app/api/feedback/route.ts index e2e72fb..064b931 100644 --- a/app/api/feedback/route.ts +++ b/app/api/feedback/route.ts @@ -6,6 +6,7 @@ import { NextRequest } from "next/server"; import { z } from "zod"; import { getSessionUser } from "@/lib/pool/access"; import { rateLimit } from "@/lib/rate-limit"; +import { clientIpFromForwardedFor } from "@/lib/rate-limit-core"; import { apiOk, apiError } from "@/lib/api"; import { createFeedback } from "@/lib/feedback/submit"; @@ -46,7 +47,9 @@ const schema = z.object({ export async function POST(req: NextRequest) { const user = await getSessionUser(); - const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "anon"; + // Rightmost XFF hop (edge-appended) — the leftmost is client-spoofable, which + // would let an anonymous submitter mint a fresh rate-limit bucket per request. + const ip = clientIpFromForwardedFor(req.headers.get("x-forwarded-for")); const key = user ? `feedback:${user.id}` : `feedback:ip:${ip}`; const rl = await rateLimit(key, 10, 5 * 60_000); if (!rl.ok) return apiError("rate limited", 429); diff --git a/app/api/giphy/search/route.ts b/app/api/giphy/search/route.ts index 11acbbf..7781616 100644 --- a/app/api/giphy/search/route.ts +++ b/app/api/giphy/search/route.ts @@ -6,6 +6,7 @@ import { NextRequest } from "next/server"; import { z } from "zod"; import { getSessionUser } from "@/lib/pool/access"; +import { rateLimit } from "@/lib/rate-limit"; import { env, giphyEnabled } from "@/lib/env"; import { mapGiphyResults } from "@/lib/chat/giphy"; import { apiOk, apiError } from "@/lib/api"; @@ -27,6 +28,11 @@ export async function GET(req: NextRequest) { // Defensive: the UI hides the button when Giphy is disabled. if (!giphyEnabled) return apiOk({ results: [] }); + // Every call spends the shared GIPHY_API_KEY quota, so throttle per user like + // the chat routes (generous for a human paging the picker, a wall for a loop). + const rl = await rateLimit(`giphy:${user.id}`, 30, 60_000); + if (!rl.ok) return apiError("Slow down a moment and try again.", 429); + let parsed: z.infer; try { parsed = querySchema.parse({ diff --git a/lib/auth/claim.ts b/lib/auth/claim.ts index 2670b27..0d36985 100644 --- a/lib/auth/claim.ts +++ b/lib/auth/claim.ts @@ -52,8 +52,11 @@ export async function claimEntriesForUser( // claim path (CSV import + sign-in, the primary way contestants enroll) // would silently push a FREE pool past its member cap, bypassing the gate // that joinPool/acceptInvite enforce. At cap, the entry is still bound to - // the account above; the owner upgrades to Premium to seat them. The - // count→create isn't atomic (same documented MVP tradeoff as joinPool). + // the account above; the owner upgrades to Premium to seat them. The same + // per-pool advisory lock joinPool/acceptInvite take serializes this + // count→create against theirs — without it a concurrent join and claim + // both count the pre-insert total and overshoot the cap by one. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${entry.poolId}))`; const pool = await tx.pool.findUnique({ where: { id: entry.poolId }, select: { tier: true }, diff --git a/lib/env.ts b/lib/env.ts index 4567ff1..952f1d2 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -21,7 +21,10 @@ const schema = z.object({ // links are logged to the server console for local development. EMAIL_SERVER: z.string().default(""), EMAIL_FROM: z.string().default("Bracketeer "), - CRON_SECRET: z.string().min(1), + // Guards the internet-reachable, result-mutating /api/cron/* routes — a + // trivially-guessable value here would let anyone write scores, so require + // real entropy (prod uses 48 chars; AUTH_SECRET already requires 16+). + CRON_SECRET: z.string().min(16, "CRON_SECRET must be at least 16 characters"), // Sports data API (optional — manual result entry works without it). SPORTS_API_KEY: z.string().default(""), SPORTS_API_BASE: z.string().default("https://v3.football.api-sports.io"), diff --git a/lib/odds/extras.ts b/lib/odds/extras.ts index a13216f..40f4857 100644 --- a/lib/odds/extras.ts +++ b/lib/odds/extras.ts @@ -22,8 +22,13 @@ import { toGoalscorerProbs, } from "@/lib/odds/map"; import { loadCodedMatches } from "@/lib/odds/coded"; +import { extrasPollDue } from "@/lib/odds/schedule"; export interface OddsExtrasSummary { + // True when the run spent no credits because the last extras poll is recent + // (see extrasPollDue) — the cron's in-memory daily bucket resets on restart, + // so this DB-backed guard is what actually bounds the metered spend. + skipped?: boolean; totalsFetched: number; totalsUpdated: number; spreadsFetched: number; @@ -55,6 +60,18 @@ export async function pollOddsExtras(): Promise { }); if (!tournament) return summary; + // Restart-proof daily throttle: the outright rows carry the last run's + // fetchedAt, so a cron redeploy/crash-loop (which resets the in-memory daily + // bucket) can't re-spend the 4+ metered credits until the interval elapses. + const lastRun = await prisma.teamOutright.findFirst({ + where: { tournamentId: tournament.id }, + orderBy: { fetchedAt: "desc" }, + select: { fetchedAt: true }, + }); + if (!extrasPollDue(lastRun?.fetchedAt?.getTime() ?? null, Date.now())) { + return { ...summary, skipped: true }; + } + // --- Totals: update existing MatchOdds rows only. The h2h poll owns row // creation; updateMany simply no-ops (0 rows) until that row exists. --- try { diff --git a/lib/odds/schedule.test.ts b/lib/odds/schedule.test.ts index 1365b96..e3967e0 100644 --- a/lib/odds/schedule.test.ts +++ b/lib/odds/schedule.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { snapshotDue, snapshotKickoffRange, + extrasPollDue, + EXTRAS_MIN_INTERVAL_MS, EARLY_WINDOW_START_MS, PRE_WINDOW_START_MS, HALF_WINDOW_START_MS, @@ -80,3 +82,14 @@ describe("snapshotKickoffRange", () => { expect(gt.getTime()).toBeLessThan(lt.getTime()); }); }); + +describe("extrasPollDue", () => { + it("fires when never fetched and after the interval, not inside it", () => { + const now = Date.parse("2026-07-02T12:00:00Z"); + expect(extrasPollDue(null, now)).toBe(true); + // A cron restart minutes after a successful run must NOT re-spend credits. + expect(extrasPollDue(now - 5 * 60_000, now)).toBe(false); + expect(extrasPollDue(now - EXTRAS_MIN_INTERVAL_MS + 1, now)).toBe(false); + expect(extrasPollDue(now - EXTRAS_MIN_INTERVAL_MS, now)).toBe(true); + }); +}); diff --git a/lib/odds/schedule.ts b/lib/odds/schedule.ts index f2c326a..cd1d6f0 100644 --- a/lib/odds/schedule.ts +++ b/lib/odds/schedule.ts @@ -59,3 +59,17 @@ export function snapshotKickoffRange(now: number): { gt: Date; lt: Date } { lt: new Date(now - EARLY_WINDOW_START_MS), }; } + +// Minimum spacing between extras polls (totals/spreads/outrights — 4+ credits per +// run). The cron's own 24 h bucket is in-memory and resets on every restart, and +// the cron service restarts with each deploy — so the DB timestamp of the last +// run, checked through this policy, is the ceiling that actually holds. 20 h (not +// 24) so one poll per calendar day still fires even as the cron's daily bucket +// boundary drifts around restarts. +export const EXTRAS_MIN_INTERVAL_MS = 20 * 60 * 60_000; + +// Whether an extras poll may spend credits now, given when the last one ran +// (null = never). Pure for testing; pollOddsExtras supplies the DB timestamp. +export function extrasPollDue(lastFetchedAtMs: number | null, now: number): boolean { + return lastFetchedAtMs == null || now - lastFetchedAtMs >= EXTRAS_MIN_INTERVAL_MS; +} diff --git a/lib/rate-limit-core.ts b/lib/rate-limit-core.ts index bb65a4d..4c05ccf 100644 --- a/lib/rate-limit-core.ts +++ b/lib/rate-limit-core.ts @@ -17,6 +17,18 @@ export interface Decision { next: Bucket; } +// The client IP for rate-limit keying, from an X-Forwarded-For header. Uses the +// RIGHTMOST hop — the one the platform edge (Railway) appends — never the +// leftmost, which is client-supplied and trivially spoofed (a spoofer minting a +// fresh leftmost value per request would get a fresh bucket per request, i.e. no +// limit at all). With a single hop the two coincide. Best-effort: a misconfigured +// proxy weakens this, but these are abuse throttles, not auth controls. +export function clientIpFromForwardedFor(header: string | null): string { + if (!header) return "anon"; + const hops = header.split(",").map((h) => h.trim()).filter(Boolean); + return hops.length ? hops[hops.length - 1] : "anon"; +} + // Given the stored bucket (or null) decide whether this request is allowed and // what the bucket should become. `now` is injected so the rule is deterministic. export function decideRateLimit( diff --git a/lib/rate-limit.test.ts b/lib/rate-limit.test.ts index 59433df..66185b5 100644 --- a/lib/rate-limit.test.ts +++ b/lib/rate-limit.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { decideRateLimit } from "./rate-limit-core"; +import { decideRateLimit, clientIpFromForwardedFor } from "./rate-limit-core"; // The window math is the pure, unit-tested core; the async rateLimit wrapper just // persists `next` and returns `result` under an advisory lock. @@ -38,3 +38,18 @@ describe("decideRateLimit", () => { expect(blocked.next).toEqual(next); // unchanged while blocked }); }); + +describe("clientIpFromForwardedFor", () => { + it("takes the RIGHTMOST hop (edge-appended), so a spoofed leftmost value is ignored", () => { + expect(clientIpFromForwardedFor("6.6.6.6, 203.0.113.9")).toBe("203.0.113.9"); + expect(clientIpFromForwardedFor("a, b, 198.51.100.4")).toBe("198.51.100.4"); + }); + + it("handles single hops, whitespace, and missing headers", () => { + expect(clientIpFromForwardedFor("203.0.113.9")).toBe("203.0.113.9"); + expect(clientIpFromForwardedFor(" 203.0.113.9 , ")).toBe("203.0.113.9"); + expect(clientIpFromForwardedFor("")).toBe("anon"); + expect(clientIpFromForwardedFor(null)).toBe("anon"); + expect(clientIpFromForwardedFor(",,")).toBe("anon"); + }); +}); diff --git a/scripts/cron.mjs b/scripts/cron.mjs index 88b09a7..35e2357 100644 --- a/scripts/cron.mjs +++ b/scripts/cron.mjs @@ -18,6 +18,11 @@ if (!base || !secret) { } const TICK_MS = 60_000; // live-score cadence +// Jobs run sequentially, so one hung request (typically the web service draining +// during a redeploy) would otherwise stall every later job — including the +// live-score poll — for undici's ~5 min default. Bound each call instead; the +// next tick retries. +const HIT_TIMEOUT_MS = 30_000; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function hit(path) { @@ -25,6 +30,7 @@ async function hit(path) { const res = await fetch(`${base}${path}`, { method: "POST", headers: { "x-cron-secret": secret }, + signal: AbortSignal.timeout(HIT_TIMEOUT_MS), }); const body = await res.text(); console.log(`${path} ${res.status}: ${body}`); From ffb9a70fd4f167e437bdc2ed1cd7935e4a5d5135 Mon Sep 17 00:00:00 2001 From: dommango-sys Date: Thu, 2 Jul 2026 17:49:37 -0400 Subject: [PATCH 7/7] fix: challenge profile gating and UI polish - getKnockoutChallengeProfile now requires board membership (which carries the verified-email anti-Sybil gate): it previously rendered gated entries' full picks with a fabricated rank, and its pick-share denominators now match the public board population. - MD3 form/breakdown pin kickoff day/time labels to DISPLAY_TZ (the form was a hydration mismatch, the breakdown rendered UTC times and split late fixtures across wrong day headers). - Chat pins its own scroll box instead of scrollIntoView, which scrolled every ancestor and yanked live match pages down to the embedded chat on mount. - R32Countdown seeds its passed state from the clock so a past kickoff no longer flashes a stale countdown banner on first paint. --- app/challenge/md3/Md3ChallengeForm.tsx | 16 +++++++++++++--- app/challenge/md3/Md3PickBreakdown.tsx | 17 +++++++++++++++-- app/pool/[code]/Chat.tsx | 7 +++++-- app/pool/[code]/R32Countdown.tsx | 6 +++++- lib/challenge/knockout-dashboard.ts | 15 ++++++++++----- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/app/challenge/md3/Md3ChallengeForm.tsx b/app/challenge/md3/Md3ChallengeForm.tsx index 6f0a95a..cb05fa9 100644 --- a/app/challenge/md3/Md3ChallengeForm.tsx +++ b/app/challenge/md3/Md3ChallengeForm.tsx @@ -6,18 +6,28 @@ import { saveMd3ChallengeEntry, type SaveMd3ChallengeState } from "./actions"; import type { Md3FixtureVM } from "@/lib/pool/md3-view"; import { Flag } from "@/app/pool/[code]/Flag"; import { WinProbBar } from "@/app/pool/[code]/WinProbBar"; +import { DISPLAY_TZ } from "@/lib/tz"; -// Group fixtures by kickoff day for light visual chunking. +// Group fixtures by kickoff day for light visual chunking. Pinned to DISPLAY_TZ +// like every other match surface: this is a client component, so an unpinned +// locale format renders in the server's zone (UTC on Railway) then re-renders in +// the viewer's — a hydration mismatch, with late kickoffs grouped under the +// wrong day on first paint. function dayLabel(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { + return new Date(iso).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", + timeZone: DISPLAY_TZ, }); } function kickoffLabel(iso: string): string { - return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + return new Date(iso).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: DISPLAY_TZ, + }); } const SCORE_BOX = diff --git a/app/challenge/md3/Md3PickBreakdown.tsx b/app/challenge/md3/Md3PickBreakdown.tsx index 27360d1..2acecf4 100644 --- a/app/challenge/md3/Md3PickBreakdown.tsx +++ b/app/challenge/md3/Md3PickBreakdown.tsx @@ -1,6 +1,7 @@ import type { Md3View, Md3FixtureVM } from "@/lib/pool/md3-view"; import { Flag } from "@/app/pool/[code]/Flag"; import { WinProbBar } from "@/app/pool/[code]/WinProbBar"; +import { DISPLAY_TZ } from "@/lib/tz"; // The MD3 analogue of the knockout Profile: a read-only, per-fixture breakdown of // one entry's predictions vs. the live/final results, with the points each pick @@ -9,12 +10,24 @@ import { WinProbBar } from "@/app/pool/[code]/WinProbBar"; // Group-stage accent, matching the ScoreCards / form fixture cards. const GROUP_ACCENT = "var(--pitch)"; +// Pinned to DISPLAY_TZ like every other match surface — this renders on the +// server (UTC on Railway), where an unpinned format shows UTC times and splits +// the late Eastern-evening fixtures across the wrong day headers. function kickoffLabel(iso: string): string { - return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + return new Date(iso).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: DISPLAY_TZ, + }); } function dayLabel(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); + return new Date(iso).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + timeZone: DISPLAY_TZ, + }); } function Stat({ label, value }: { label: string; value: string | number }) { diff --git a/app/pool/[code]/Chat.tsx b/app/pool/[code]/Chat.tsx index 1943ca9..618226e 100644 --- a/app/pool/[code]/Chat.tsx +++ b/app/pool/[code]/Chat.tsx @@ -101,8 +101,11 @@ export function Chat({ useEffect(() => { // Newest messages render at the bottom (normal chat), so keep the view pinned - // there as they arrive. - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + // there as they arrive. Scroll the message list's own scroll box, NOT + // scrollIntoView — that scrolls every ancestor including the window, which + // yanked the whole match-detail page down to its embedded chat on mount. + const list = bottomRef.current?.parentElement; + if (list) list.scrollTop = list.scrollHeight; }, [messages]); // Single POST path shared by the text composer and the GIF picker. Returns diff --git a/app/pool/[code]/R32Countdown.tsx b/app/pool/[code]/R32Countdown.tsx index e5048f1..dce26a5 100644 --- a/app/pool/[code]/R32Countdown.tsx +++ b/app/pool/[code]/R32Countdown.tsx @@ -10,7 +10,11 @@ const LABEL = "text-xs font-bold uppercase tracking-[0.08em] text-ink-3"; // R32 is under way — and keeps the server component pure (no render-time clock). export function R32Countdown({ target, label }: { target: string; label: string }) { const targetMs = new Date(target).getTime(); - const [passed, setPassed] = useState(false); + // Seed from the clock, not `false`: once the kickoff is in the past both the + // server render and the client's first paint agree it's gone, instead of + // flashing a stale "kicks off in · · ·" banner that the mount effect removes. + // (Server and client clocks only disagree across the kickoff instant itself.) + const [passed, setPassed] = useState(() => Date.now() >= targetMs); useEffect(() => { const tick = () => setPassed(Date.now() >= targetMs); diff --git a/lib/challenge/knockout-dashboard.ts b/lib/challenge/knockout-dashboard.ts index 2db9cd2..a70c34d 100644 --- a/lib/challenge/knockout-dashboard.ts +++ b/lib/challenge/knockout-dashboard.ts @@ -159,9 +159,13 @@ export async function getKnockoutChallengeProfile(entryId: string): Promise r.entryId)); const eligible = entries.filter((e) => { + if (!boardIds.has(e.id)) return false; try { return isKnockoutEntryComplete(pickRowsToSubmission(e.picks).picks); } catch { @@ -172,7 +176,8 @@ export async function getKnockoutChallengeProfile(entryId: string): Promise r.entryId === entryId); - const projected = row?.projected ?? 0; + if (!row) return null; + const projected = row.projected ?? 0; const projectionRow = projection.hasData ? projection.rows.find((r) => r.entryId === entryId) @@ -188,9 +193,9 @@ export async function getKnockoutChallengeProfile(entryId: string): Promise 0 ? projected : undefined, - rank: row?.rank ?? Math.max(1, board.length), + rank: row.rank, entryCount: board.length, picks: pickRowsToSubmission(entry.picks).picks, results: asResults(tournament.officialResults),