perf : cache
CI / Verify (push) Successful in 1m56s
CI / Build immutable images and deploy (push) Successful in 3m12s

This commit is contained in:
2026-10-08 23:21:34 +07:00 Unverified
parent 36063fc214
commit 9580407a90
58 changed files with 5797 additions and 5336 deletions
+2 -2
View File
@@ -10,9 +10,9 @@ import { AdminHeader } from "@/components/admin/admin-header";
import { EditorPresence } from "@/components/admin/editor-presence";
import { Button } from "@/components/ui/button";
import { getAdminSession } from "@/lib/auth/server";
import { getCachedCatalogOptions } from "@/lib/cache/catalog";
import { toTeamDraft } from "@/lib/guides/team-draft";
import {
getCatalogOptions,
getAdminGuideContent,
getGuideEditorHeader,
} from "@/lib/guides/queries";
@@ -54,7 +54,7 @@ export default async function StructuredEditorPage({
?? guide.sections.find((section) => section.slug === page)?.title
?? page;
const [catalog, content] = await Promise.all([
getCatalogOptions(),
getCachedCatalogOptions(),
getAdminGuideContent(guide.slug, page),
]);
if (!catalog || !content) notFound();
+57
View File
@@ -0,0 +1,57 @@
import { Suspense } from "react";
import Link from "next/link";
import { ArrowLeftIcon } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { AdminHeader } from "@/components/admin/admin-header";
import { GuideSectionNav } from "@/components/admin/guide-section-nav";
import { CommentBoardContent } from "@/components/comments/comment-board-content";
import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton";
import { Button } from "@/components/ui/button";
import Image from "@/components/ui/resilient-image";
import { getAdminSession } from "@/lib/auth/server";
import { getGuideEditorHeader } from "@/lib/guides/queries";
export const instant = false;
export default async function GuideCommentPage({
params,
}: {
params: Promise<{ character: string }>;
}) {
await connection();
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const { character } = await params;
const guide = await getGuideEditorHeader(character);
if (!guide) notFound();
const extras = guide.sections
.filter((section) => section.kind === "extra")
.map(({ id, slug, title }) => ({ id, slug, title }));
return (
<div className="min-h-svh">
<AdminHeader />
<main lang="en" className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 pb-4 sm:px-6 sm:pb-6 lg:px-8 lg:pb-8">
<div className="flex flex-col">
<header className="flex items-center gap-4">
<Button variant="ghost" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="Back to dashboard">
<ArrowLeftIcon />
</Button>
<div className="relative size-16 shrink-0 overflow-hidden rounded-lg border">
<Image src={guide.coverUrl} alt={guide.name} fill sizes="64px" className="object-cover" />
</div>
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{guide.name}</p>
<h1 className="font-heading text-3xl font-semibold">Comment</h1>
</div>
</header>
<GuideSectionNav guideId={guide.id} characterKey={guide.characterKey} current="comment" extras={extras} />
</div>
<Suspense fallback={<CommentBoardSkeleton />}>
<CommentBoardContent target={`guide:${guide.id}`} />
</Suspense>
</main>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ admin: vi.fn(), updateTag: vi.fn(), save: vi.fn() }));
vi.mock("next/cache", () => ({ updateTag: mocks.updateTag }));
vi.mock("@/lib/auth/server", () => ({ requireAdmin: mocks.admin }));
vi.mock("@/lib/audit-log", () => ({ auditActor: () => ({ id: "admin", label: "Admin" }) }));
vi.mock("@/lib/glossary-repository", () => ({ createGlossaryAlias: vi.fn(), deleteGlossaryAlias: vi.fn() }));
vi.mock("@/lib/catalog/sync", () => ({ cancelCatalogSyncJob: vi.fn(), getCatalogSyncJob: vi.fn(), startCatalogSyncJob: vi.fn() }));
vi.mock("@/lib/guides/queries", () => ({
getAdminGuideContentById: vi.fn(), getAdminTeamImportGuide: vi.fn(),
listAdminTeamImportsForCharacter: vi.fn(), listAdminTeamImportGuides: vi.fn(),
}));
vi.mock("@/lib/guides/mutations", () => ({
createExtraSection: mocks.save, createGuide: mocks.save, deleteExtraSection: mocks.save,
permanentlyDeleteGuide: mocks.save, reorderExtraSections: mocks.save, restoreGuide: mocks.save,
saveArtifacts: mocks.save, saveConstellations: mocks.save, saveExtraSection: mocks.save,
saveTeams: mocks.save, setGuideVisibility: mocks.save, saveWeapons: mocks.save,
trashGuide: mocks.save, updateGuideBasics: mocks.save,
GuideVersionConflictError: class extends Error { currentVersion = 9; },
}));
import { moveGuideToTrash, restoreStructuredGuide, saveArtifactSection, saveGuideExtra, saveGuideOverview, saveWeaponSection } from "./actions";
import { GuideVersionConflictError } from "@/lib/guides/mutations";
beforeEach(() => {
vi.resetAllMocks();
mocks.admin.mockResolvedValue({ user: { id: "admin" } });
mocks.save.mockResolvedValue({ version: 10 });
});
describe("guide save cache freshness", () => {
const guideId = "550e8400-e29b-41d4-a716-446655440000";
it.each([saveArtifactSection, saveGuideOverview, saveWeaponSection, moveGuideToTrash, restoreStructuredGuide])(
"expires the public page immediately after a successful action", async (action) => {
expect(await action(guideId, {})).toMatchObject({ status: "saved", version: 10 });
expect(mocks.updateTag).toHaveBeenCalledWith(`buzz:page:${guideId}`);
},
);
it("invalidates custom extras without changing their section id or version input", async () => {
const sectionId = "550e8400-e29b-41d4-a716-446655440001";
const input = { expectedVersion: 9 };
await saveGuideExtra(guideId, sectionId, input);
expect(mocks.save).toHaveBeenCalledWith(guideId, sectionId, input, expect.any(Object));
expect(mocks.updateTag).toHaveBeenCalledWith(`buzz:page:${guideId}`);
});
it("preserves conflicts without invalidating or reporting a save", async () => {
mocks.save.mockRejectedValue(new GuideVersionConflictError(9));
expect(await saveArtifactSection(guideId, {})).toMatchObject({ status: "conflict", currentVersion: 9 });
expect(mocks.updateTag).not.toHaveBeenCalled();
});
it("authorizes before writes or cache invalidation", async () => {
mocks.admin.mockRejectedValue(new Error("unauthorized"));
await expect(saveArtifactSection(guideId, {})).rejects.toThrow("unauthorized");
expect(mocks.save).not.toHaveBeenCalled();
expect(mocks.updateTag).not.toHaveBeenCalled();
});
});
+20 -15
View File
@@ -3,7 +3,7 @@
import { updateTag } from "next/cache";
import * as z from "zod";
import { GLOSSARY_CACHE_TAG, pageCacheTag } from "@/lib/cache/tags";
import { DIRECTORY_CACHE_TAG, GLOSSARY_CACHE_TAG, pageCacheTag } from "@/lib/cache/tags";
import {
createGlossaryAlias,
deleteGlossaryAlias,
@@ -56,9 +56,11 @@ function mutationContext(
return { actor: auditActor(admin.user), overwrite: intent?.overwrite };
}
async function runGuideAction(action: () => Promise<{ version?: number }>): Promise<GuideActionResult> {
async function runGuideAction(action: () => Promise<{ version?: number }>, guideId: string): Promise<GuideActionResult> {
try {
const result = await action();
updateTag(pageCacheTag(guideId));
updateTag(DIRECTORY_CACHE_TAG);
return { status: "saved", version: result.version };
} catch (cause) {
if (cause instanceof GuideVersionConflictError) {
@@ -75,6 +77,8 @@ export async function createStructuredGuide(input: unknown): Promise<CreateGuide
const admin = await requireAdmin();
try {
const guide = await createGuide(input, mutationContext(admin));
updateTag(pageCacheTag(guide.id));
updateTag(DIRECTORY_CACHE_TAG);
return { status: "saved", version: guide.version, location: `/admin/${guide.characterKey}` };
} catch (cause) {
return { status: "error", message: cause instanceof Error ? cause.message : "สร้าง Guide ไม่สำเร็จ" };
@@ -83,27 +87,27 @@ export async function createStructuredGuide(input: unknown): Promise<CreateGuide
export async function saveWeaponSection(guideId: string, input: unknown, intent?: SaveIntent) {
const admin = await requireAdmin();
return runGuideAction(() => saveWeapons(guideId, input, mutationContext(admin, intent)));
return runGuideAction(() => saveWeapons(guideId, input, mutationContext(admin, intent)), guideId);
}
export async function saveGuideOverview(guideId: string, input: unknown, intent?: SaveIntent) {
const admin = await requireAdmin();
return runGuideAction(() => updateGuideBasics(guideId, input, mutationContext(admin, intent)));
return runGuideAction(() => updateGuideBasics(guideId, input, mutationContext(admin, intent)), guideId);
}
export async function saveArtifactSection(guideId: string, input: unknown, intent?: SaveIntent) {
const admin = await requireAdmin();
return runGuideAction(() => saveArtifacts(guideId, input, mutationContext(admin, intent)));
return runGuideAction(() => saveArtifacts(guideId, input, mutationContext(admin, intent)), guideId);
}
export async function saveConstellationSection(guideId: string, input: unknown, intent?: SaveIntent) {
const admin = await requireAdmin();
return runGuideAction(() => saveConstellations(guideId, input, mutationContext(admin, intent)));
return runGuideAction(() => saveConstellations(guideId, input, mutationContext(admin, intent)), guideId);
}
export async function saveTeamSection(guideId: string, input: unknown, intent?: SaveIntent) {
const admin = await requireAdmin();
return runGuideAction(() => saveTeams(guideId, input, mutationContext(admin, intent)));
return runGuideAction(() => saveTeams(guideId, input, mutationContext(admin, intent)), guideId);
}
export async function listTeamImportGuides(currentGuideId: string) {
@@ -157,6 +161,7 @@ export async function createGuideExtra(guideId: string, input: unknown) {
mutationContext(admin),
);
updateTag(pageCacheTag(guideId));
updateTag(DIRECTORY_CACHE_TAG);
return {
status: "saved" as const,
version: section.version,
@@ -185,8 +190,8 @@ export async function saveGuideExtra(
const parsedSectionId = z.string().uuid().parse(sectionId);
const result = await runGuideAction(() =>
saveExtraSection(parsedGuideId, parsedSectionId, input, mutationContext(admin, intent)),
guideId,
);
if (result.status === "saved") updateTag(pageCacheTag(guideId));
return result;
}
@@ -198,8 +203,8 @@ export async function deleteGuideExtra(guideId: string, sectionId: string) {
z.string().uuid().parse(sectionId),
mutationContext(admin),
),
guideId,
);
if (result.status === "saved") updateTag(pageCacheTag(guideId));
return result;
}
@@ -211,19 +216,19 @@ export async function reorderGuideExtras(guideId: string, input: unknown) {
input,
mutationContext(admin),
),
guideId,
);
if (result.status === "saved") updateTag(pageCacheTag(guideId));
return result;
}
export async function moveGuideToTrash(guideId: string) {
const admin = await requireAdmin();
return runGuideAction(() => trashGuide(guideId, mutationContext(admin)));
return runGuideAction(() => trashGuide(guideId, mutationContext(admin)), guideId);
}
export async function restoreStructuredGuide(guideId: string) {
const admin = await requireAdmin();
return runGuideAction(() => restoreGuide(guideId, mutationContext(admin)));
return runGuideAction(() => restoreGuide(guideId, mutationContext(admin)), guideId);
}
export async function setStructuredGuideVisibility(
@@ -237,7 +242,7 @@ export async function setStructuredGuideVisibility(
}).safeParse(input);
if (!parsed.success) return { status: "error" as const, message: "ข้อมูลการเผยแพร่ไม่ถูกต้อง" };
if (!parsed.data.isPublic) {
return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin)));
return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin)), guideId);
}
const guide = await getAdminGuideContentById(guideId);
@@ -252,12 +257,12 @@ export async function setStructuredGuideVisibility(
if (issues.length) {
return { status: "error" as const, message: issues.join(" • "), issues };
}
return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin)));
return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin)), guideId);
}
export async function deleteStructuredGuide(guideId: string) {
const admin = await requireAdmin();
return runGuideAction(() => permanentlyDeleteGuide(guideId, mutationContext(admin)));
return runGuideAction(() => permanentlyDeleteGuide(guideId, mutationContext(admin)), guideId);
}
export async function startCatalogSync() {
+2 -2
View File
@@ -12,8 +12,8 @@ export default async function AdminCommentsPage({ searchParams }: { searchParams
const initialTarget = query.target;
const initialComment = initialTarget && query.comment && /^[0-9a-f-]{36}$/i.test(query.comment) ? query.comment : undefined;
const [initial, targets] = await Promise.all([listComments({ viewer, inbox: true, grouped: true, target: initialTarget }), listCommentTargets()]);
return <><AdminHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
<header><h1 className="font-heading text-3xl font-semibold">ความคิดเห็น</h1><p className="mt-2 text-muted-foreground">ตอบกลับและจัดการความคิดเห็นจากทุกไกด์</p></header>
return <><AdminHeader /><main lang="en" className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
<header><h1 className="font-heading text-3xl font-semibold">Comments</h1><p className="mt-2 text-muted-foreground">Reply to and manage comments from every guide</p></header>
<AdminCommentInbox key={`${initialTarget ?? "all"}:${initialComment ?? ""}`} initialTarget={initialTarget} initialComment={initialComment} initial={initial} targets={targets} />
</main></>;
}
+3 -2
View File
@@ -7,13 +7,14 @@ import { CreateGuideFlow } from "@/components/admin/create-guide-flow";
import { AdminHeader } from "@/components/admin/admin-header";
import { Button } from "@/components/ui/button";
import { getAdminSession } from "@/lib/auth/server";
import { getActiveCatalog, listGuideCards } from "@/lib/guides/queries";
import { getCachedCatalogOptions } from "@/lib/cache/catalog";
import { listGuideCards } from "@/lib/guides/queries";
export default async function CreateGuidePage() {
await connection();
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const [catalog, guides] = await Promise.all([
getActiveCatalog(),
getCachedCatalogOptions(),
listGuideCards(),
]);
if (!catalog) redirect("/admin?sync=required");
+2 -2
View File
@@ -5,13 +5,13 @@ import { AdminHeader } from "@/components/admin/admin-header";
import { GlossaryManager } from "@/components/admin/glossary-manager";
import { getAdminSession } from "@/lib/auth/server";
import { listGlossaryAliases } from "@/lib/glossary-repository";
import { getCatalogOptions } from "@/lib/guides/queries";
import { getCachedCatalogOptions } from "@/lib/cache/catalog";
export default async function GlossaryPage() {
await connection();
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const [catalog, aliases] = await Promise.all([
getCatalogOptions(),
getCachedCatalogOptions(),
listGlossaryAliases(),
]);
if (!catalog) redirect("/admin");
+2 -2
View File
@@ -2,12 +2,12 @@ import { redirect } from "next/navigation";
import { connection } from "next/server";
import { ConstellationImageGenerator } from "@/components/admin/constellation-image-generator";
import { getActiveCatalog } from "@/lib/guides/queries";
import { getCachedCatalogOptions } from "@/lib/cache/catalog";
export default async function ConstellationImagePage() {
await connection();
const catalog = await getActiveCatalog();
const catalog = await getCachedCatalogOptions();
if (!catalog) redirect("/admin?sync=required");
return (
+2 -2
View File
@@ -2,13 +2,13 @@ import { redirect } from "next/navigation";
import { connection } from "next/server";
import { OverviewImageGenerator } from "@/components/admin/overview-image-generator";
import { getActiveCatalog } from "@/lib/guides/queries";
import { getCachedActiveCatalog } from "@/lib/cache/catalog";
import { elementOrder } from "@/lib/overview-image";
export default async function OverviewImagePage() {
await connection();
const catalog = await getActiveCatalog();
const catalog = await getCachedActiveCatalog();
if (!catalog) redirect("/admin?sync=required");
const elementIconUrls = Object.fromEntries(
+2 -2
View File
@@ -2,12 +2,12 @@ import { redirect } from "next/navigation";
import { connection } from "next/server";
import { TeamImageGenerator } from "@/components/admin/team-image-generator";
import { getActiveCatalog } from "@/lib/guides/queries";
import { getCachedCatalogOptions } from "@/lib/cache/catalog";
export default async function TeamImagePage() {
await connection();
const catalog = await getActiveCatalog();
const catalog = await getCachedCatalogOptions();
if (!catalog) redirect("/admin?sync=required");
return (
+2 -2
View File
@@ -2,12 +2,12 @@ import { redirect } from "next/navigation";
import { connection } from "next/server";
import { WeaponImageGenerator } from "@/components/admin/weapon-image-generator";
import { getActiveCatalog } from "@/lib/guides/queries";
import { getCachedCatalogOptions } from "@/lib/cache/catalog";
export default async function WeaponImagePage() {
await connection();
const catalog = await getActiveCatalog();
const catalog = await getCachedCatalogOptions();
if (!catalog) redirect("/admin?sync=required");
return (
+2 -1
View File
@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { CommentLocaleProvider } from "@/components/comments/comment-locale";
import { AdminUnreadCommentsProvider } from "@/components/comments/admin-unread-comments";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -13,7 +14,7 @@ export default function AdminLayout({ children }: LayoutProps<"/admin">) {
</main>
}
>
<AdminUnreadCommentsProvider>{children}</AdminUnreadCommentsProvider>
<CommentLocaleProvider locale="en"><AdminUnreadCommentsProvider>{children}</AdminUnreadCommentsProvider></CommentLocaleProvider>
</Suspense>
</TooltipProvider>
);
+10
View File
@@ -0,0 +1,10 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function AdminLoading() {
return <main role="status" aria-label="Loading admin page" className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
<span className="sr-only">Loading admin page…</span>
<Skeleton className="h-10 w-64 max-w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</main>;
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { StygianEditor } from "@/components/admin/stygian-editor";
import { StygianSyncButton } from "@/components/admin/stygian-sync-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { getAdminSession } from "@/lib/auth/server";
import { getActiveCatalog } from "@/lib/guides/queries";
import { getCachedCatalogOptions } from "@/lib/cache/catalog";
import { getStygianSchedule, listStygianSchedules } from "@/lib/stygian/repository";
export default async function AdminStygianPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
@@ -16,7 +16,7 @@ export default async function AdminStygianPage({ searchParams }: { searchParams:
const schedules = await listStygianSchedules();
const raw = Array.isArray(query.schedule) ? query.schedule[0] : query.schedule;
const selectedId = Number(raw) || schedules[0]?.scheduleId;
const [schedule, catalog] = await Promise.all([selectedId ? getStygianSchedule(selectedId) : null, getActiveCatalog()]);
const [schedule, catalog] = await Promise.all([selectedId ? getStygianSchedule(selectedId) : null, getCachedCatalogOptions()]);
return (
<div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
<header><p className="text-sm font-medium text-primary">Stygian Onslaught</p><h1 className="font-heading text-4xl font-semibold">Stygian Guide</h1></header>
+2 -2
View File
@@ -11,7 +11,7 @@ import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { buttonVariants } from "@/components/ui/button";
import { getCachedStygianSchedule, getCachedStygianSchedules } from "@/lib/cache/stygian";
import { getActiveCatalog } from "@/lib/guides/queries";
import { getCachedCatalogOptions } from "@/lib/cache/catalog";
export const instant = false;
@@ -22,7 +22,7 @@ export default async function StygianPage({ searchParams }: { searchParams: Prom
const rawSchedule = Array.isArray(query.schedule) ? query.schedule[0] : query.schedule;
const requestedId = Number(rawSchedule);
const selectedId = schedules.some((entry) => entry.scheduleId === requestedId) ? requestedId : schedules[0]?.scheduleId;
const [schedule, catalog] = await Promise.all([selectedId ? getCachedStygianSchedule(selectedId) : null, getActiveCatalog()]);
const [schedule, catalog] = await Promise.all([selectedId ? getCachedStygianSchedule(selectedId) : null, getCachedCatalogOptions()]);
return (
<div className="flex min-h-svh flex-col bg-background">
<LiveRefresh endpoint="/api/events/stygian" />