mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-05-06 04:55:34 -05:00
Fix improper caching of tags fetching
This commit is contained in:
parent
82886ec2bb
commit
99f2e05627
20
src/app/api/tags/refresh/route.ts
Normal file
20
src/app/api/tags/refresh/route.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
74
src/data/tags.ts
Normal 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
7
src/types/catalogTag.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user