From 99f2e05627ef19901fdbcc1715cf154376cfa91a Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Sun, 29 Mar 2026 00:50:30 -1000 Subject: [PATCH] Fix improper caching of tags fetching --- src/app/api/tags/refresh/route.ts | 20 ++++++ src/app/discover/actions.ts | 30 +-------- src/app/hack/[slug]/edit/page.tsx | 4 +- src/app/hack/actions.ts | 17 ++--- src/app/submit/actions.ts | 14 ++-- src/app/submit/page.tsx | 4 +- src/components/Hack/HackEditForm.tsx | 6 +- src/components/Hack/HackForm.tsx | 6 +- src/components/Hack/HackSubmitForm.tsx | 5 +- src/components/Submit/SubmitPageClient.tsx | 12 +++- src/components/Submit/TagSelector.tsx | 27 ++++---- src/data/tags.ts | 74 ++++++++++++++++++++++ src/types/catalogTag.ts | 7 ++ 13 files changed, 159 insertions(+), 67 deletions(-) create mode 100644 src/app/api/tags/refresh/route.ts create mode 100644 src/data/tags.ts create mode 100644 src/types/catalogTag.ts diff --git a/src/app/api/tags/refresh/route.ts b/src/app/api/tags/refresh/route.ts new file mode 100644 index 0000000..b49ebb8 --- /dev/null +++ b/src/app/api/tags/refresh/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import { checkUserRoles } from "@/utils/user"; +import { createClient } from "@/utils/supabase/server"; +import { TAGS_CATALOG_CACHE_TAG } from "@/data/tags"; + +export async function GET(req: NextRequest) { + const supa = await createClient(); + const { data: user } = await supa.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { isAdmin } = await checkUserRoles(supa); + if (!isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + revalidateTag(TAGS_CATALOG_CACHE_TAG); + return NextResponse.redirect(new URL("/discover", req.url)); +} diff --git a/src/app/discover/actions.ts b/src/app/discover/actions.ts index ff03e9b..5480f25 100644 --- a/src/app/discover/actions.ts +++ b/src/app/discover/actions.ts @@ -2,6 +2,7 @@ import { unstable_cache as cache } from "next/cache"; import { createServiceClient } from "@/utils/supabase/server"; +import { getCachedTagsWithUsage, buildTagFilterGroups } from "@/data/tags"; import { sortOrderedTags, OrderedTag, getCoverUrls } from "@/utils/format"; import { HackCardAttributes } from "@/components/HackCard"; import type { DiscoverSortOption } from "@/types/discover"; @@ -228,11 +229,8 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise = {}; - const ungrouped: string[] = []; - const unique = new Set(); - if (allTagRows) { - for (const row of allTagRows as any[]) { - const name: string = row.name; - if (unique.has(name)) continue; - unique.add(name); - const category: string | null = row.category ?? null; - if (category) { - if (!groups[category]) groups[category] = []; - groups[category].push(name); - } else { - ungrouped.push(name); - } - } - // Sort for stable UI - Object.keys(groups).forEach((k) => groups[k].sort((a, b) => a.localeCompare(b))); - ungrouped.sort((a, b) => a.localeCompare(b)); - } - return { hacks: mapped, tagGroups: groups, diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx index f60997b..4b372b6 100644 --- a/src/app/hack/[slug]/edit/page.tsx +++ b/src/app/hack/[slug]/edit/page.tsx @@ -5,6 +5,7 @@ import { FaChevronLeft, FaChevronRight, FaPlus } from "react-icons/fa6"; import Link from "next/link"; import { sortOrderedTags, getCoverUrls } from "@/utils/format"; import { checkEditPermission } from "@/utils/hack"; +import { getCachedTagsWithUsage } from "@/data/tags"; interface EditPageProps { params: Promise<{ slug: string }>; @@ -12,6 +13,7 @@ interface EditPageProps { export default async function EditHackPage({ params }: EditPageProps) { const { slug } = await params; + const catalogTags = await getCachedTagsWithUsage(); const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) { @@ -119,7 +121,7 @@ export default async function EditHackPage({ params }: EditPageProps) {
- +
); diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index 41b006f..35442a2 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -8,6 +8,7 @@ import { redirect } from "next/navigation"; import { APIEmbed } from "discord-api-types/v10"; import { sendDiscordMessageEmbed } from "@/utils/discord"; import { checkEditPermission, checkPatchEditPermission } from "@/utils/hack"; +import { getCachedTagsWithUsage, resolveTagIdsInOrder } from "@/data/tags"; export async function updateHack(args: { slug: string; @@ -71,19 +72,9 @@ export async function updateHack(args: { } if (args.tags) { - // Resolve desired tag IDs from names and upsert links with explicit ordering - const { data: existingTags, error: tagErr } = await supabase - .from("tags") - .select("id, name") - .in("name", args.tags); - if (tagErr) return { ok: false, error: tagErr.message } as const; - - const byName = new Map((existingTags || []).map((t: any) => [t.name as string, t.id as number])); - - // Desired IDs in the exact order provided by the caller - const desiredIds = args.tags - .map((name) => byName.get(name)) - .filter((id): id is number => typeof id === "number"); + const catalog = await getCachedTagsWithUsage(); + const resolved = resolveTagIdsInOrder(args.tags, catalog); + const desiredIds = resolved.map((t) => t.id); const { data: currentLinks, error: curErr } = await supabase .from("hack_tags") diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index 7a18d80..1717776 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -7,6 +7,7 @@ import { sendDiscordMessageEmbed } from "@/utils/discord"; import { APIEmbed } from "discord-api-types/v10"; import { slugify } from "@/utils/format"; import { checkEditPermission, checkPatchEditPermission } from "@/utils/hack"; +import { getCachedTagsWithUsage, resolveTagIdsInOrder } from "@/data/tags"; type HackInsert = TablesInsert<"hacks">; @@ -103,15 +104,12 @@ export async function prepareSubmission(formData: FormData) { return { ok: false, error: insertErr.message } as const; } - // Tags: restrict to existing only + // Tags: restrict to existing only (order follows form submission) if (tags.length > 0) { - const { data: existingTags, error: tagErr } = await supabase - .from("tags") - .select("id, name") - .in("name", tags); - if (tagErr) return { ok: false, error: tagErr.message } as const; - if (existingTags && existingTags.length > 0) { - const hackTags = existingTags.map((t, i) => ({ hack_slug: slug, tag_id: t.id, order: i + 1 })); + const catalog = await getCachedTagsWithUsage(); + const resolved = resolveTagIdsInOrder(tags, catalog); + if (resolved.length > 0) { + const hackTags = resolved.map((t, i) => ({ hack_slug: slug, tag_id: t.id, order: i + 1 })); const { error: htErr } = await supabase.from("hack_tags").insert(hackTags); if (htErr) return { ok: false, error: htErr.message } as const; } diff --git a/src/app/submit/page.tsx b/src/app/submit/page.tsx index 6632f66..727f5f7 100644 --- a/src/app/submit/page.tsx +++ b/src/app/submit/page.tsx @@ -1,4 +1,5 @@ import SubmitPageClient from "@/components/Submit/SubmitPageClient"; +import { getCachedTagsWithUsage } from "@/data/tags"; import { createClient } from "@/utils/supabase/server"; import SubmitAuthOverlay from "@/components/Submit/SubmitAuthOverlay"; import { Metadata } from "next"; @@ -11,6 +12,7 @@ export const metadata: Metadata = { }; export default async function SubmitPage() { + const catalogTags = await getCachedTagsWithUsage(); const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); let needsInitialSetup = false; @@ -46,7 +48,7 @@ export default async function SubmitPage() {
- +
{!user ? (
- +
diff --git a/src/components/Hack/HackForm.tsx b/src/components/Hack/HackForm.tsx index e60d3f8..b8d3f2e 100644 --- a/src/components/Hack/HackForm.tsx +++ b/src/components/Hack/HackForm.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import type { CatalogTagRow } from "@/types/catalogTag"; import HackSubmitForm from "@/components/Hack/HackSubmitForm"; import HackEditForm from "@/components/Hack/HackEditForm"; @@ -12,12 +13,14 @@ interface HackFormCreateProps { isArchive?: boolean; permissionFrom?: string; customCreator?: string; + catalogTags: CatalogTagRow[]; } interface HackFormEditProps { mode: "edit"; slug: string; initial: React.ComponentProps["initial"]; + catalogTags: CatalogTagRow[]; } export type HackFormProps = HackFormCreateProps | HackFormEditProps; @@ -29,9 +32,10 @@ export default function HackForm(props: HackFormProps) { isArchive={props.isArchive} permissionFrom={props.permissionFrom} customCreator={props.customCreator} + catalogTags={props.catalogTags} />; } - return ; + return ; } diff --git a/src/components/Hack/HackSubmitForm.tsx b/src/components/Hack/HackSubmitForm.tsx index 7cbbacc..a5544ca 100644 --- a/src/components/Hack/HackSubmitForm.tsx +++ b/src/components/Hack/HackSubmitForm.tsx @@ -25,6 +25,7 @@ import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js" import { sha1Hex } from "@/utils/hash"; import { platformAccept, setDraftCovers, getDraftCovers, deleteDraftCovers } from "@/utils/idb"; import { slugify, sortOrderedTags } from "@/utils/format"; +import type { CatalogTagRow } from "@/types/catalogTag"; function SortableCoverItem({ id, index, url, filename, onRemove }: { id: string; index: number; url: string; filename: string; onRemove: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); @@ -66,6 +67,7 @@ interface HackSubmitFormProps { isArchive?: boolean; permissionFrom?: string; customCreator?: string; + catalogTags: CatalogTagRow[]; } export default function HackSubmitForm({ @@ -73,6 +75,7 @@ export default function HackSubmitForm({ isArchive = false, permissionFrom = undefined, customCreator = undefined, + catalogTags, }: HackSubmitFormProps) { const MAX_COVERS = 10; const { profile, user } = useAuthContext(); @@ -899,7 +902,7 @@ export default function HackSubmitForm({
- +
diff --git a/src/components/Submit/SubmitPageClient.tsx b/src/components/Submit/SubmitPageClient.tsx index 97a0ca2..e85141b 100644 --- a/src/components/Submit/SubmitPageClient.tsx +++ b/src/components/Submit/SubmitPageClient.tsx @@ -1,10 +1,19 @@ "use client"; import React from "react"; +import type { CatalogTagRow } from "@/types/catalogTag"; import HackForm from "@/components/Hack/HackForm"; import ArchiveModeSelector from "@/components/Submit/ArchiveModeSelector"; -export default function SubmitPageClient({ canCreateArchive, dummy }: { canCreateArchive: boolean; dummy: boolean }) { +export default function SubmitPageClient({ + canCreateArchive, + dummy, + catalogTags, +}: { + canCreateArchive: boolean; + dummy: boolean; + catalogTags: CatalogTagRow[]; +}) { const [showModeSelector, setShowModeSelector] = React.useState(canCreateArchive); const [customCreator, setCustomCreator] = React.useState(undefined); const [permissionFrom, setPermissionFrom] = React.useState(undefined); @@ -27,5 +36,6 @@ export default function SubmitPageClient({ canCreateArchive, dummy }: { canCreat isArchive={isArchive} permissionFrom={permissionFrom} customCreator={customCreator} + catalogTags={catalogTags} />; } diff --git a/src/components/Submit/TagSelector.tsx b/src/components/Submit/TagSelector.tsx index a0343f2..7b35aed 100644 --- a/src/components/Submit/TagSelector.tsx +++ b/src/components/Submit/TagSelector.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import type { CatalogTagRow } from "@/types/catalogTag"; import { createClient } from "@/utils/supabase/client"; import { MdTune } from "react-icons/md"; import { CATEGORY_ICONS, getCategoryIcon } from "@/components/Icons/tagCategories"; @@ -19,16 +20,13 @@ import { import { SortableContext, arrayMove, useSortable, rectSortingStrategy } from "@dnd-kit/sortable"; import { RxDragHandleDots2 } from "react-icons/rx"; -type TagRow = { - id: number; - name: string; - category: string | null; - popularity: number; -}; +type TagRow = CatalogTagRow; export interface TagSelectorProps { value: string[]; onChange: (next: string[]) => void; + /** When set, skips client Supabase fetch (use server-cached catalog). */ + catalogTags?: CatalogTagRow[]; } type CategoryIconType = React.ComponentType> | null; @@ -114,11 +112,11 @@ function SortableSelectedTag({ ); } -export default function TagSelector({ value, onChange }: TagSelectorProps) { +export default function TagSelector({ value, onChange, catalogTags }: TagSelectorProps) { const supabase = createClient(); const [query, setQuery] = React.useState(""); - const [allTags, setAllTags] = React.useState([]); - const [loading, setLoading] = React.useState(false); + const [allTags, setAllTags] = React.useState(() => catalogTags ?? []); + const [loading, setLoading] = React.useState(() => catalogTags === undefined); const [activeCategory, _setActiveCategory] = React.useState(null); const searchInputRef = React.useRef(null); const categoryRefs = React.useRef>({}); @@ -143,6 +141,8 @@ export default function TagSelector({ value, onChange }: TagSelectorProps) { }, []); React.useEffect(() => { + if (catalogTags !== undefined) return; + let cancelled = false; (async () => { try { setLoading(true); @@ -156,12 +156,15 @@ export default function TagSelector({ value, onChange }: TagSelectorProps) { popularity: t.usage?.[0]?.count || 0, })); rows.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name)); - setAllTags(rows); + if (!cancelled) setAllTags(rows); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } })(); - }, [supabase]); + return () => { + cancelled = true; + }; + }, [catalogTags, supabase]); const grouped = React.useMemo(() => { const map = new Map(); diff --git a/src/data/tags.ts b/src/data/tags.ts new file mode 100644 index 0000000..f8f8e08 --- /dev/null +++ b/src/data/tags.ts @@ -0,0 +1,74 @@ +import { unstable_cache as cache } from "next/cache"; +import { createServiceClient } from "@/utils/supabase/server"; +import type { CatalogTagRow } from "@/types/catalogTag"; + +/** Must match `revalidateTag()` in src/app/api/tags/refresh/route.ts. */ +export const TAGS_CATALOG_CACHE_TAG = "tags-catalog"; + +const TAGS_CATALOG_REVALIDATE_SECONDS = 86400; // 24 hours + +export type { CatalogTagRow }; + +function mapAndSortTagRows(data: unknown): CatalogTagRow[] { + const rows: CatalogTagRow[] = ((data as any[]) || []).map((t: any) => ({ + id: t.id as number, + name: t.name as string, + category: (t.category ?? null) as string | null, + popularity: t.usage?.[0]?.count || 0, + })); + rows.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name)); + return rows; +} + +export async function getCachedTagsWithUsage(): Promise { + const runner = cache( + async () => { + const supabase = await createServiceClient(); + const { data, error } = await supabase + .from("tags") + .select("id,name,category,usage: hack_tags (count)"); + if (error) throw error; + return mapAndSortTagRows(data); + }, + ["tags-catalog-v1"], + { revalidate: TAGS_CATALOG_REVALIDATE_SECONDS, tags: [TAGS_CATALOG_CACHE_TAG] } + ); + return runner(); +} + +export function buildTagFilterGroups(rows: CatalogTagRow[]): { + tagGroups: Record; + ungroupedTags: string[]; +} { + const groups: Record = {}; + const ungrouped: string[] = []; + const unique = new Set(); + for (const row of rows) { + const name = row.name; + if (unique.has(name)) continue; + unique.add(name); + const category = row.category ?? null; + if (category) { + if (!groups[category]) groups[category] = []; + groups[category].push(name); + } else { + ungrouped.push(name); + } + } + Object.keys(groups).forEach((k) => groups[k].sort((a, b) => a.localeCompare(b))); + ungrouped.sort((a, b) => a.localeCompare(b)); + return { tagGroups: groups, ungroupedTags: ungrouped }; +} + +export function resolveTagIdsInOrder( + names: string[], + catalog: CatalogTagRow[] +): { id: number; name: string }[] { + const byName = new Map(catalog.map((t) => [t.name, t] as const)); + const out: { id: number; name: string }[] = []; + for (const name of names) { + const row = byName.get(name); + if (row) out.push({ id: row.id, name: row.name }); + } + return out; +} diff --git a/src/types/catalogTag.ts b/src/types/catalogTag.ts new file mode 100644 index 0000000..8332165 --- /dev/null +++ b/src/types/catalogTag.ts @@ -0,0 +1,7 @@ +/** Tag row for catalog UI and cached tag fetches (see src/data/tags.ts). */ +export type CatalogTagRow = { + id: number; + name: string; + category: string | null; + popularity: number; +};