From 1905425f3b026258363eeecf4f8b0a46190e87e4 Mon Sep 17 00:00:00 2001 From: Ronan Hevenor Date: Sun, 25 Oct 2026 13:04:22 -0400 Subject: [PATCH] fix(sports): cap /sports/all pages, admin-only layout delete, fetch standings and schedule together - /sports/all returns 404 past page 1000 instead of passing a huge offset to Postgres - Sports Layout can only be deleted by admins (it had no delete rule, so any logged-in user could) - /sports fetches standings and the score-strip schedule in parallel; the schedule covers every standings sport and is narrowed to the card's sports afterwards, so a slow feed costs one wait instead of two Co-Authored-By: Claude Opus 5.5 --- app/(frontend)/[section]/page.tsx | 41 +++++++++++++++++++----------- app/(frontend)/sports/all/page.tsx | 3 +++ collections/SportsPageLayout.ts | 5 ++++ 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/app/(frontend)/[section]/page.tsx b/app/(frontend)/[section]/page.tsx index 8a7edcd..049a510 100644 --- a/app/(frontend)/[section]/page.tsx +++ b/app/(frontend)/[section]/page.tsx @@ -12,7 +12,7 @@ import type { StandingsTeam } from '@/components/Sports/SportsStandings'; import type { FeaturedFocus } from '@/components/Sports/sportsFormat'; import { getSportsStandings, type SportStandings } from '@/lib/standings'; import { getSportsSchedule, type SportSchedule } from '@/lib/schedule'; -import type { StandingsOverride } from '@/lib/standingsSchedule'; +import { STANDINGS_SPORTS, type StandingsOverride } from '@/lib/standingsSchedule'; import { RPI_SCHOOL_NAME } from '@/collections/Schools'; import { Article as PayloadArticle, Media } from '@/payload-types'; import { Article as ComponentArticle, GameTeam } from '@/components/FrontPage/types'; @@ -606,8 +606,23 @@ export default async function SectionPageRoute({ params }: Args) { // Table may not exist yet } - // Standings for the sports the calendar (and any editor override) picks - sportsStandings = await getSportsStandings({ overrides: standingsOverrides }); + // Standings for the sports the calendar (and any editor override) picks. + // The score strip follows the standings' sports, but which ones those are + // is only known once the tables are in, so the schedule is fetched for + // every standings sport alongside them and narrowed afterwards; a slow + // feed then costs one wait instead of two in a row. When editors switch + // the strip off, RPI Athletics isn't contacted at all, and a failure + // falls back to the plain header rather than an error page. + const [standings, schedule] = await Promise.all([ + getSportsStandings({ overrides: standingsOverrides }), + sportsShowScoreStrip + ? getSportsSchedule(STANDINGS_SPORTS.map((s) => s.sport)).catch((err: Error) => { + console.warn(`[schedule] ${err.message}`); + return [] as SportSchedule[]; + }) + : Promise.resolve([] as SportSchedule[]), + ]); + sportsStandings = standings; // Logos + athletics links for every team in the tables, matched to // Schools by short name (falling back to full name) @@ -632,18 +647,14 @@ export default async function SectionPageRoute({ params }: Args) { } } - // Score strip: RPI's recent finals and next game in each standings sport. - // Opponents take the short name and logo from Schools when there is one, - // so editors' logo fixes carry over; otherwise RPI Athletics' own. When - // editors switch the strip off, RPI Athletics isn't contacted at all, and - // a failure here falls back to the plain header rather than an error page. - if (sportsShowScoreStrip) { - try { - sportsSchedule = await getSportsSchedule(sportsStandings.map((s) => s.sport)); - } catch (err) { - console.warn(`[schedule] ${(err as Error).message}`); - } - } + // Score strip: RPI's recent finals and next game in each standings sport, + // in the standings card's order. Opponents take the short name and logo + // from Schools when there is one, so editors' logo fixes carry over; + // otherwise RPI Athletics' own. + const scheduleBySport = new Map(schedule.map((s) => [s.sport, s])); + sportsSchedule = sportsStandings + .map((s) => scheduleBySport.get(s.sport)) + .filter((s): s is SportSchedule => s !== undefined); const opponentNames = [...new Set(sportsSchedule.flatMap((s) => s.games.map((g) => g.opponent.name)))]; if (opponentNames.length > 0) { try { diff --git a/app/(frontend)/sports/all/page.tsx b/app/(frontend)/sports/all/page.tsx index 6de05d3..2966070 100644 --- a/app/(frontend)/sports/all/page.tsx +++ b/app/(frontend)/sports/all/page.tsx @@ -14,6 +14,8 @@ import { notFound } from 'next/navigation'; export const revalidate = 60; const PER_PAGE = 24; +// Far past any real archive; bigger numbers would overflow the query offset +const MAX_PAGE = 1000; export const metadata: Metadata = { title: 'Sports Stories', @@ -31,6 +33,7 @@ export default async function SportsAllStoriesPage({ searchParams }: Args) { // Whole, finite page numbers only ("Infinity" or "abc" mean page 1) const parsedPage = Math.floor(Number(params.page)); const requestedPage = Number.isFinite(parsedPage) && parsedPage > 1 ? parsedPage : 1; + if (requestedPage > MAX_PAGE) notFound(); const payload = await getPayload({ config }); const published: Where[] = [{ section: { equals: 'sports' } }, { _status: { equals: 'published' } }]; diff --git a/collections/SportsPageLayout.ts b/collections/SportsPageLayout.ts index 6a9fed3..48f48a7 100644 --- a/collections/SportsPageLayout.ts +++ b/collections/SportsPageLayout.ts @@ -32,6 +32,11 @@ const SportsPageLayout: CollectionConfig = { const roles = (user as unknown as { roles?: string[] }).roles || [] return roles.some((role: string) => ['admin', 'eic', 'editor'].includes(role)) }, + delete: ({ req: { user } }) => { + if (!user) return false + const roles = (user as unknown as { roles?: string[] }).roles || [] + return roles.includes('admin') + }, }, fields: [ {