Fix improper caching of tags fetching

This commit is contained in:
Jared Schoeny 2026-03-29 00:50:30 -10:00
parent 82886ec2bb
commit 99f2e05627
13 changed files with 159 additions and 67 deletions

View File

@ -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));
}

View File

@ -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<Discove
}
});
// Fetch all tags with category to build UI groups
const { data: allTagRows, error: allTagsError } = await supabase
.from("tags")
.select("name,category");
if (allTagsError) throw allTagsError;
const catalogTags = await getCachedTagsWithUsage();
const { tagGroups: groups, ungroupedTags: ungrouped } = buildTagFilterGroups(catalogTags);
// Fetch profiles for author names
const { data: profiles, error: profilesError } = await supabase
@ -299,28 +297,6 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
});
}
// Build tag groups
const groups: Record<string, string[]> = {};
const ungrouped: string[] = [];
const unique = new Set<string>();
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,

View File

@ -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) {
</div>
</div>
<div className="mt-4 lg:mt-8">
<HackForm mode="edit" slug={slug} initial={initial} />
<HackForm mode="edit" slug={slug} initial={initial} catalogTags={catalogTags} />
</div>
</div>
);

View File

@ -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")

View File

@ -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;
}

View File

@ -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() {
</div>
</div>
<div className="mt-8">
<SubmitPageClient canCreateArchive={canCreateArchive} dummy={!user || needsInitialSetup} />
<SubmitPageClient canCreateArchive={canCreateArchive} dummy={!user || needsInitialSetup} catalogTags={catalogTags} />
</div>
{!user ? (
<SubmitAuthOverlay

View File

@ -11,10 +11,12 @@ import { updateHack, saveHackCovers, presignCoverUpload } from "@/app/hack/actio
import SortableCovers from "@/components/Hack/SortableCovers";
import Select from "@/components/Primitives/Select";
import type { Database } from "@/types/db";
import type { CatalogTagRow } from "@/types/catalogTag";
import { FiExternalLink } from "react-icons/fi";
interface HackEditFormProps {
slug: string;
catalogTags: CatalogTagRow[];
initial: {
title: string;
summary: string;
@ -36,7 +38,7 @@ interface HackEditFormProps {
};
}
export default function HackEditForm({ slug, initial }: HackEditFormProps) {
export default function HackEditForm({ slug, initial, catalogTags }: HackEditFormProps) {
const supabase = createClient();
const MAX_COVERS = 10;
const [title, setTitle] = React.useState(initial.title);
@ -363,7 +365,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
)}
</div>
<div className={`rounded-md ring-1 ring-inset ${tagsChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'ring-transparent'} p-1`}>
<TagSelector value={tags} onChange={setTags} />
<TagSelector value={tags} onChange={setTags} catalogTags={catalogTags} />
</div>
</div>
</div>

View File

@ -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<typeof HackEditForm>["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 <HackEditForm slug={props.slug} initial={props.initial} />;
return <HackEditForm slug={props.slug} initial={props.initial} catalogTags={props.catalogTags} />;
}

View File

@ -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({
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Tags <span className="text-red-500">*</span></label>
<TagSelector value={tags} onChange={setTags} />
<TagSelector value={tags} onChange={setTags} catalogTags={catalogTags} />
</div>
<div className="grid gap-1">

View File

@ -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<string | undefined>(undefined);
const [permissionFrom, setPermissionFrom] = React.useState<string | undefined>(undefined);
@ -27,5 +36,6 @@ export default function SubmitPageClient({ canCreateArchive, dummy }: { canCreat
isArchive={isArchive}
permissionFrom={permissionFrom}
customCreator={customCreator}
catalogTags={catalogTags}
/>;
}

View File

@ -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<React.SVGProps<SVGSVGElement>> | 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<TagRow[]>([]);
const [loading, setLoading] = React.useState(false);
const [allTags, setAllTags] = React.useState<TagRow[]>(() => catalogTags ?? []);
const [loading, setLoading] = React.useState(() => catalogTags === undefined);
const [activeCategory, _setActiveCategory] = React.useState<string | "advanced" | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
const categoryRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
@ -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<string, TagRow[]>();

74
src/data/tags.ts Normal file
View File

@ -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<CatalogTagRow[]> {
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<string, string[]>;
ungroupedTags: string[];
} {
const groups: Record<string, string[]> = {};
const ungrouped: string[] = [];
const unique = new Set<string>();
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;
}

7
src/types/catalogTag.ts Normal file
View File

@ -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;
};