refactor: remove catalog sync jobs and version history
CI / Verify (push) Successful in 58s
CI / Build immutable images and deploy (push) Successful in 1m19s

This commit is contained in:
2026-08-31 23:21:14 +07:00 Unverified
parent 737b54bff8
commit bb06108899
14 changed files with 6865 additions and 299 deletions
+3 -4
View File
@@ -16,9 +16,9 @@ import { builtInTemplates } from "@/lib/templates";
import { persistTemplateApplication } from "@/lib/templates/persist"; import { persistTemplateApplication } from "@/lib/templates/persist";
import { import {
createGuide, createGuide,
enqueueCatalogSync,
permanentlyDeleteGuide, permanentlyDeleteGuide,
restoreGuide, restoreGuide,
runCatalogSync,
saveArtifacts, saveArtifacts,
saveConstellations, saveConstellations,
saveExtraSections, saveExtraSections,
@@ -274,10 +274,9 @@ export async function deleteStructuredGuide(guideId: string) {
} }
export async function startCatalogSync() { export async function startCatalogSync() {
const admin = await requireAdmin(); await requireAdmin();
try { try {
const job = await enqueueCatalogSync(admin.user.id); return await runCatalogSync();
return { status: "saved" as const, jobId: job.id };
} catch (cause) { } catch (cause) {
return { status: "error" as const, message: cause instanceof Error ? cause.message : "เริ่ม Sync ไม่สำเร็จ" }; return { status: "error" as const, message: cause instanceof Error ? cause.message : "เริ่ม Sync ไม่สำเร็จ" };
} }
+1 -1
View File
@@ -20,7 +20,7 @@ export default async function CreateGuidePage() {
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8"> <main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
<header className="flex items-center gap-4"> <header className="flex items-center gap-4">
<Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button> <Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button>
<div><p className="text-sm text-muted-foreground">Catalog {catalog.version}</p><h1 className="font-heading text-3xl font-semibold">สร้าง Guide ใหม่</h1></div> <div><p className="text-sm text-muted-foreground">Catalog ล่าสุดจาก Lunaris</p><h1 className="font-heading text-3xl font-semibold">สร้าง Guide ใหม่</h1></div>
</header> </header>
<CreateGuideFlow characters={catalog.characters} existingKeys={guides.map((guide) => guide.characterKey)} /> <CreateGuideFlow characters={catalog.characters} existingKeys={guides.map((guide) => guide.characterKey)} />
</main> </main>
+3 -4
View File
@@ -4,15 +4,14 @@ import { connection } from "next/server";
import { StructuredDashboard } from "@/components/admin/structured-dashboard"; import { StructuredDashboard } from "@/components/admin/structured-dashboard";
import { AdminHeader } from "@/components/admin/admin-header"; import { AdminHeader } from "@/components/admin/admin-header";
import { getAdminSession } from "@/lib/auth/server"; import { getAdminSession } from "@/lib/auth/server";
import { getActiveCatalog, getLatestCatalogSyncJob, listGuideCards } from "@/lib/guides/queries"; import { getActiveCatalog, listGuideCards } from "@/lib/guides/queries";
export default async function AdminPage() { export default async function AdminPage() {
await connection(); await connection();
if (!(await getAdminSession())) redirect("/admin/login"); if (!(await getAdminSession())) redirect("/admin/login");
const [guides, trash, syncJob, catalog] = await Promise.all([ const [guides, trash, catalog] = await Promise.all([
listGuideCards(), listGuideCards(),
listGuideCards(true), listGuideCards(true),
getLatestCatalogSyncJob(),
getActiveCatalog(), getActiveCatalog(),
]); ]);
return ( return (
@@ -24,7 +23,7 @@ export default async function AdminPage() {
<h1 className="font-heading text-3xl font-semibold">Guide Dashboard</h1> <h1 className="font-heading text-3xl font-semibold">Guide Dashboard</h1>
<p className="mt-2 text-muted-foreground">จัดการ Guide แบบภาพ-first และบันทึกขึ้นเว็บไซต์ทันที</p> <p className="mt-2 text-muted-foreground">จัดการ Guide แบบภาพ-first และบันทึกขึ้นเว็บไซต์ทันที</p>
</header> </header>
<StructuredDashboard guides={guides} trash={trash} syncJob={syncJob} catalogVersion={catalog?.version ?? null} /> <StructuredDashboard guides={guides} trash={trash} catalogReady={Boolean(catalog)} />
</main> </main>
</div> </div>
); );
+5 -14
View File
@@ -4,11 +4,10 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PlusIcon, RefreshCwIcon, RotateCcwIcon, Trash2Icon } from "lucide-react"; import { PlusIcon, RefreshCwIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useEffect, useTransition } from "react"; import { useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteStructuredGuide, moveGuideToTrash, restoreStructuredGuide, startCatalogSync } from "@/app/admin/actions"; import { deleteStructuredGuide, moveGuideToTrash, restoreStructuredGuide, startCatalogSync } from "@/app/admin/actions";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -17,17 +16,10 @@ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
type GuideCard = { id: string; characterKey: string; name: string; overview: string; coverUrl: string; version: number; updatedAt: Date; trashedAt: Date | null }; type GuideCard = { id: string; characterKey: string; name: string; overview: string; coverUrl: string; version: number; updatedAt: Date; trashedAt: Date | null };
type SyncJob = { id: string; status: string; version: string | null; progress: number; total: number; error: string | null } | null;
export function StructuredDashboard({ guides, trash, syncJob, catalogVersion }: { guides: GuideCard[]; trash: GuideCard[]; syncJob: SyncJob; catalogVersion: string | null }) { export function StructuredDashboard({ guides, trash, catalogReady }: { guides: GuideCard[]; trash: GuideCard[]; catalogReady: boolean }) {
const router = useRouter(); const router = useRouter();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const syncing = syncJob?.status === "pending" || syncJob?.status === "processing";
useEffect(() => {
if (!syncing) return;
const timer = window.setInterval(() => router.refresh(), 3_000);
return () => window.clearInterval(timer);
}, [router, syncing]);
function run(action: () => Promise<{ status: string; message?: string }>, success: string) { function run(action: () => Promise<{ status: string; message?: string }>, success: string) {
startTransition(async () => { startTransition(async () => {
@@ -40,10 +32,9 @@ export function StructuredDashboard({ guides, trash, syncJob, catalogVersion }:
return ( return (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2"><Badge variant="outline">Lunaris {catalogVersion ?? "ยังไม่ Sync"}</Badge>{syncJob ? <Badge variant={syncJob.status === "failed" ? "destructive" : "secondary"}>{syncJob.status}{syncing && syncJob.total ? ` ${syncJob.progress}/${syncJob.total}` : ""}</Badge> : null}</div> <div className="flex items-center gap-2"><Badge variant="outline">Lunaris {catalogReady ? "พร้อมใช้งาน" : "ยังไม่ Sync"}</Badge></div>
<div className="flex gap-2"><Button variant="outline" disabled={pending || syncing} onClick={() => run(startCatalogSync, "เริ่ม Sync แล้ว")}>{pending || syncing ? <Spinner data-icon="inline-start" /> : <RefreshCwIcon data-icon="inline-start" />}Sync Catalog</Button><Button render={<Link href="/admin/create" />} nativeButton={false} disabled={!catalogVersion}><PlusIcon data-icon="inline-start" />สร้าง Guide</Button></div> <div className="flex gap-2"><Button variant="outline" disabled={pending} onClick={() => run(startCatalogSync, "Sync สำเร็จแล้ว")}>{pending ? <Spinner data-icon="inline-start" /> : <RefreshCwIcon data-icon="inline-start" />}Sync Catalog</Button><Button render={<Link href="/admin/create" />} nativeButton={false} disabled={!catalogReady}><PlusIcon data-icon="inline-start" />สร้าง Guide</Button></div>
</div> </div>
{syncJob?.status === "failed" ? <Alert variant="destructive"><AlertTitle>Sync ไม่สำเร็จ</AlertTitle><AlertDescription>{syncJob.error}</AlertDescription></Alert> : null}
{guides.length ? ( {guides.length ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">{guides.map((guide) => ( <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">{guides.map((guide) => (
<Card key={guide.id} className="overflow-hidden py-0"> <Card key={guide.id} className="overflow-hidden py-0">
@@ -53,7 +44,7 @@ export function StructuredDashboard({ guides, trash, syncJob, catalogVersion }:
<CardFooter className="justify-between pb-5"><Button render={<Link href={`/admin/${guide.characterKey}`} />} nativeButton={false}>แก้ไข Guide</Button><AlertDialog><AlertDialogTrigger render={<Button variant="ghost" size="icon" />}><Trash2Icon /><span className="sr-only">ย้าย {guide.name} ไปถังขยะ</span></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ย้าย {guide.name} ไปถังขยะ?</AlertDialogTitle><AlertDialogDescription>Guide จะหายจากหน้าเว็บไซต์ทันที และสามารถกู้คืนได้ภายหลัง</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => moveGuideToTrash(guide.id), "ย้ายไปถังขยะแล้ว")}>ย้ายไปถังขยะ</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></CardFooter> <CardFooter className="justify-between pb-5"><Button render={<Link href={`/admin/${guide.characterKey}`} />} nativeButton={false}>แก้ไข Guide</Button><AlertDialog><AlertDialogTrigger render={<Button variant="ghost" size="icon" />}><Trash2Icon /><span className="sr-only">ย้าย {guide.name} ไปถังขยะ</span></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ย้าย {guide.name} ไปถังขยะ?</AlertDialogTitle><AlertDialogDescription>Guide จะหายจากหน้าเว็บไซต์ทันที และสามารถกู้คืนได้ภายหลัง</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => moveGuideToTrash(guide.id), "ย้ายไปถังขยะแล้ว")}>ย้ายไปถังขยะ</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></CardFooter>
</Card> </Card>
))}</div> ))}</div>
) : <Empty className="border"><EmptyHeader><EmptyTitle>ยังไม่มี Guide</EmptyTitle><EmptyDescription>Sync Catalog แล้วเลือกตัวละครเพื่อสร้าง Guide แรก</EmptyDescription></EmptyHeader><EmptyContent><Button render={<Link href="/admin/create" />} nativeButton={false} disabled={!catalogVersion}>สร้าง Guide</Button></EmptyContent></Empty>} ) : <Empty className="border"><EmptyHeader><EmptyTitle>ยังไม่มี Guide</EmptyTitle><EmptyDescription>Sync Catalog แล้วเลือกตัวละครเพื่อสร้าง Guide แรก</EmptyDescription></EmptyHeader><EmptyContent><Button render={<Link href="/admin/create" />} nativeButton={false} disabled={!catalogReady}>สร้าง Guide</Button></EmptyContent></Empty>}
{trash.length ? <section className="flex flex-col gap-4"><div><h2 className="font-heading text-xl font-semibold">ถังขยะ</h2><p className="text-sm text-muted-foreground">Guide ที่ซ่อนไว้จากเว็บไซต์</p></div><div className="grid gap-3 md:grid-cols-2">{trash.map((guide) => <Card key={guide.id}><CardHeader><CardTitle>{guide.name}</CardTitle><CardDescription>{guide.overview}</CardDescription><CardAction><div className="flex gap-2"><Button variant="outline" size="sm" disabled={pending} onClick={() => run(() => restoreStructuredGuide(guide.id), "กู้คืน Guide แล้ว")}><RotateCcwIcon data-icon="inline-start" />กู้คืน</Button><AlertDialog><AlertDialogTrigger render={<Button variant="destructive" size="sm" />}><Trash2Icon data-icon="inline-start" />ลบถาวร</AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ลบ {guide.name} ถาวร?</AlertDialogTitle><AlertDialogDescription>ข้อมูล Weapons, Artifacts, Constellations, Team และ Extra ทั้งหมดจะกู้คืนไม่ได้</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => deleteStructuredGuide(guide.id), "ลบ Guide ถาวรแล้ว")}>ยืนยันลบถาวร</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></div></CardAction></CardHeader></Card>)}</div></section> : null} {trash.length ? <section className="flex flex-col gap-4"><div><h2 className="font-heading text-xl font-semibold">ถังขยะ</h2><p className="text-sm text-muted-foreground">Guide ที่ซ่อนไว้จากเว็บไซต์</p></div><div className="grid gap-3 md:grid-cols-2">{trash.map((guide) => <Card key={guide.id}><CardHeader><CardTitle>{guide.name}</CardTitle><CardDescription>{guide.overview}</CardDescription><CardAction><div className="flex gap-2"><Button variant="outline" size="sm" disabled={pending} onClick={() => run(() => restoreStructuredGuide(guide.id), "กู้คืน Guide แล้ว")}><RotateCcwIcon data-icon="inline-start" />กู้คืน</Button><AlertDialog><AlertDialogTrigger render={<Button variant="destructive" size="sm" />}><Trash2Icon data-icon="inline-start" />ลบถาวร</AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ลบ {guide.name} ถาวร?</AlertDialogTitle><AlertDialogDescription>ข้อมูล Weapons, Artifacts, Constellations, Team และ Extra ทั้งหมดจะกู้คืนไม่ได้</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => deleteStructuredGuide(guide.id), "ลบ Guide ถาวรแล้ว")}>ยืนยันลบถาวร</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></div></CardAction></CardHeader></Card>)}</div></section> : null}
</div> </div>
); );
+5 -75
View File
@@ -431,23 +431,9 @@ export const outboxEvents = pgTable(
], ],
); );
export const catalogVersions = catalogSchema.table(
"version",
{
version: varchar("version", { length: 32 }).primaryKey(),
active: boolean("active").default(false).notNull(),
sourceTimestamp: bigint("source_timestamp", { mode: "number" }),
syncedAt: timestamp("synced_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("catalog_active_unique").on(table.active).where(sql`${table.active} = true`)],
);
export const catalogCharacters = catalogSchema.table( export const catalogCharacters = catalogSchema.table(
"character", "character",
{ {
version: varchar("version", { length: 32 })
.notNull()
.references(() => catalogVersions.version, { onDelete: "cascade" }),
key: varchar("key", { length: 64 }).notNull(), key: varchar("key", { length: 64 }).notNull(),
name: text("name").notNull(), name: text("name").notNull(),
element: varchar("element", { length: 48 }), element: varchar("element", { length: 48 }),
@@ -460,45 +446,15 @@ export const catalogCharacters = catalogSchema.table(
constellations: jsonb("constellations").$type<CatalogConstellation[]>().default([]).notNull(), constellations: jsonb("constellations").$type<CatalogConstellation[]>().default([]).notNull(),
talents: jsonb("talents").$type<CatalogTalent[]>().default([]).notNull(), talents: jsonb("talents").$type<CatalogTalent[]>().default([]).notNull(),
}, },
(table) => [
primaryKey({ columns: [table.version, table.key] }),
index("catalog_character_name_idx").on(table.version, table.name),
],
);
export const catalogCharacterCurrent = catalogSchema.table(
"character_current",
{
key: varchar("key", { length: 64 }).notNull(),
name: text("name").notNull(),
element: varchar("element", { length: 48 }),
rarity: integer("rarity"),
weaponType: varchar("weapon_type", { length: 64 }),
imageKey: text("image_key").notNull(),
elementIconKey: text("element_icon_key"),
splashImageKey: text("splash_image_key"),
nameCardImageKey: text("name_card_image_key"),
constellations: jsonb("constellations")
.$type<CatalogConstellation[]>()
.default([])
.notNull(),
talents: jsonb("talents")
.$type<CatalogTalent[]>()
.default([])
.notNull(),
},
(table) => [ (table) => [
primaryKey({ columns: [table.key] }), primaryKey({ columns: [table.key] }),
index("catalog_character_current_name_idx").on(table.name), index("catalog_character_name_idx").on(table.name),
], ],
); );
export const catalogWeapons = catalogSchema.table( export const catalogWeapons = catalogSchema.table(
"weapon", "weapon",
{ {
version: varchar("version", { length: 32 })
.notNull()
.references(() => catalogVersions.version, { onDelete: "cascade" }),
key: varchar("key", { length: 64 }).notNull(), key: varchar("key", { length: 64 }).notNull(),
name: text("name").notNull(), name: text("name").notNull(),
rarity: integer("rarity"), rarity: integer("rarity"),
@@ -506,53 +462,30 @@ export const catalogWeapons = catalogSchema.table(
imageKey: text("image_key").notNull(), imageKey: text("image_key").notNull(),
}, },
(table) => [ (table) => [
primaryKey({ columns: [table.version, table.key] }), primaryKey({ columns: [table.key] }),
index("catalog_weapon_name_idx").on(table.version, table.name), index("catalog_weapon_name_idx").on(table.name),
], ],
); );
export const catalogArtifacts = catalogSchema.table( export const catalogArtifacts = catalogSchema.table(
"artifact", "artifact",
{ {
version: varchar("version", { length: 32 })
.notNull()
.references(() => catalogVersions.version, { onDelete: "cascade" }),
key: varchar("key", { length: 64 }).notNull(), key: varchar("key", { length: 64 }).notNull(),
name: text("name").notNull(), name: text("name").notNull(),
rarity: integer("rarity"), rarity: integer("rarity"),
imageKey: text("image_key").notNull(), imageKey: text("image_key").notNull(),
}, },
(table) => [ (table) => [
primaryKey({ columns: [table.version, table.key] }), primaryKey({ columns: [table.key] }),
index("catalog_artifact_name_idx").on(table.version, table.name), index("catalog_artifact_name_idx").on(table.name),
], ],
); );
export const catalogSyncJobs = catalogSchema.table(
"sync_job",
{
id: uuid("id").defaultRandom().primaryKey(),
status: varchar("status", { length: 24 }).default("pending").notNull(),
version: varchar("version", { length: 32 }),
progress: integer("progress").default(0).notNull(),
total: integer("total").default(0).notNull(),
error: text("error"),
triggeredById: text("triggered_by_id").references(() => users.id, { onDelete: "set null" }),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
...timestamps,
},
(table) => [index("catalog_sync_status_idx").on(table.status, table.createdAt)],
);
export const guides = guidesSchema.table( export const guides = guidesSchema.table(
"guide", "guide",
{ {
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
characterKey: varchar("character_key", { length: 64 }).notNull(), characterKey: varchar("character_key", { length: 64 }).notNull(),
catalogVersion: varchar("catalog_version", { length: 32 })
.notNull()
.references(() => catalogVersions.version, { onDelete: "restrict" }),
slug: varchar("slug", { length: 120 }).notNull(), slug: varchar("slug", { length: 120 }).notNull(),
name: text("name").notNull(), name: text("name").notNull(),
overview: text("overview").notNull(), overview: text("overview").notNull(),
@@ -593,7 +526,6 @@ export const weaponRecommendations = guidesSchema.table(
{ {
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
catalogVersion: varchar("catalog_version", { length: 32 }).notNull(),
weaponKey: varchar("weapon_key", { length: 64 }).notNull(), weaponKey: varchar("weapon_key", { length: 64 }).notNull(),
refinement: integer("refinement").notNull(), refinement: integer("refinement").notNull(),
overallPercent: numeric("overall_percent", { precision: 8, scale: 2 }), overallPercent: numeric("overall_percent", { precision: 8, scale: 2 }),
@@ -620,7 +552,6 @@ export const artifactRecommendations = guidesSchema.table(
{ {
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
groupId: uuid("group_id").notNull().references(() => artifactGroups.id, { onDelete: "cascade" }), groupId: uuid("group_id").notNull().references(() => artifactGroups.id, { onDelete: "cascade" }),
catalogVersion: varchar("catalog_version", { length: 32 }).notNull(),
firstArtifactKey: varchar("first_artifact_key", { length: 64 }).notNull(), firstArtifactKey: varchar("first_artifact_key", { length: 64 }).notNull(),
secondArtifactKey: varchar("second_artifact_key", { length: 64 }), secondArtifactKey: varchar("second_artifact_key", { length: 64 }),
percent: numeric("percent", { precision: 8, scale: 2 }).notNull(), percent: numeric("percent", { precision: 8, scale: 2 }).notNull(),
@@ -672,7 +603,6 @@ export const teamMembers = guidesSchema.table(
{ {
teamId: uuid("team_id").notNull().references(() => teamCompositions.id, { onDelete: "cascade" }), teamId: uuid("team_id").notNull().references(() => teamCompositions.id, { onDelete: "cascade" }),
position: integer("position").notNull(), position: integer("position").notNull(),
catalogVersion: varchar("catalog_version", { length: 32 }).notNull(),
characterKey: varchar("character_key", { length: 64 }).notNull(), characterKey: varchar("character_key", { length: 64 }).notNull(),
weaponKey: varchar("weapon_key", { length: 64 }), weaponKey: varchar("weapon_key", { length: 64 }),
artifactKey: varchar("artifact_key", { length: 64 }), artifactKey: varchar("artifact_key", { length: 64 }),
+1
View File
@@ -0,0 +1 @@
DROP TABLE "catalog"."character_current" CASCADE;
+28
View File
@@ -0,0 +1,28 @@
ALTER TABLE "catalog"."artifact" DROP CONSTRAINT IF EXISTS "artifact_version_version_version_fk";--> statement-breakpoint
ALTER TABLE "catalog"."character" DROP CONSTRAINT IF EXISTS "character_version_version_version_fk";--> statement-breakpoint
ALTER TABLE "catalog"."weapon" DROP CONSTRAINT IF EXISTS "weapon_version_version_version_fk";--> statement-breakpoint
ALTER TABLE "guides"."guide" DROP CONSTRAINT IF EXISTS "guide_catalog_version_version_version_fk";--> statement-breakpoint
DELETE FROM "catalog"."artifact" WHERE "version" IS DISTINCT FROM (SELECT "version" FROM "catalog"."version" WHERE "active" = true LIMIT 1);--> statement-breakpoint
DELETE FROM "catalog"."character" WHERE "version" IS DISTINCT FROM (SELECT "version" FROM "catalog"."version" WHERE "active" = true LIMIT 1);--> statement-breakpoint
DELETE FROM "catalog"."weapon" WHERE "version" IS DISTINCT FROM (SELECT "version" FROM "catalog"."version" WHERE "active" = true LIMIT 1);--> statement-breakpoint
DROP INDEX IF EXISTS "catalog"."catalog_artifact_name_idx";--> statement-breakpoint
DROP INDEX IF EXISTS "catalog"."catalog_character_name_idx";--> statement-breakpoint
DROP INDEX IF EXISTS "catalog"."catalog_weapon_name_idx";--> statement-breakpoint
ALTER TABLE "catalog"."artifact" DROP CONSTRAINT IF EXISTS "artifact_version_key_pk";--> statement-breakpoint
ALTER TABLE "catalog"."character" DROP CONSTRAINT IF EXISTS "character_version_key_pk";--> statement-breakpoint
ALTER TABLE "catalog"."weapon" DROP CONSTRAINT IF EXISTS "weapon_version_key_pk";--> statement-breakpoint
ALTER TABLE "catalog"."artifact" ADD CONSTRAINT "artifact_key_pk" PRIMARY KEY("key");--> statement-breakpoint
ALTER TABLE "catalog"."character" ADD CONSTRAINT "character_key_pk" PRIMARY KEY("key");--> statement-breakpoint
ALTER TABLE "catalog"."weapon" ADD CONSTRAINT "weapon_key_pk" PRIMARY KEY("key");--> statement-breakpoint
ALTER TABLE "guides"."artifact_recommendation" DROP COLUMN "catalog_version";--> statement-breakpoint
ALTER TABLE "catalog"."artifact" DROP COLUMN "version";--> statement-breakpoint
ALTER TABLE "catalog"."character" DROP COLUMN "version";--> statement-breakpoint
ALTER TABLE "catalog"."weapon" DROP COLUMN "version";--> statement-breakpoint
ALTER TABLE "guides"."guide" DROP COLUMN "catalog_version";--> statement-breakpoint
ALTER TABLE "guides"."team_member" DROP COLUMN "catalog_version";--> statement-breakpoint
ALTER TABLE "guides"."weapon_recommendation" DROP COLUMN "catalog_version";--> statement-breakpoint
CREATE INDEX "catalog_artifact_name_idx" ON "catalog"."artifact" USING btree ("name");--> statement-breakpoint
CREATE INDEX "catalog_character_name_idx" ON "catalog"."character" USING btree ("name");--> statement-breakpoint
CREATE INDEX "catalog_weapon_name_idx" ON "catalog"."weapon" USING btree ("name");--> statement-breakpoint
DROP TABLE "catalog"."sync_job";--> statement-breakpoint
DROP TABLE "catalog"."version";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -106,6 +106,20 @@
"when": 1788190879635, "when": 1788190879635,
"tag": "0014_redundant_black_crow", "tag": "0014_redundant_black_crow",
"breakpoints": true "breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1788192255590,
"tag": "0015_steady_switch",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1788192979231,
"tag": "0016_uneven_infant_terrible",
"breakpoints": true
} }
] ]
} }
+21 -130
View File
@@ -1,4 +1,3 @@
import { asc, eq } from "drizzle-orm";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import * as z from "zod"; import * as z from "zod";
@@ -6,10 +5,7 @@ import * as z from "zod";
import { getDb } from "@/db/client"; import { getDb } from "@/db/client";
import { import {
catalogArtifacts, catalogArtifacts,
catalogCharacterCurrent,
catalogCharacters, catalogCharacters,
catalogSyncJobs,
catalogVersions,
catalogWeapons, catalogWeapons,
} from "@/db/schema"; } from "@/db/schema";
import { import {
@@ -63,7 +59,6 @@ const artifactSchema = z.record(
); );
interface SyncedCharacter { interface SyncedCharacter {
version: string;
key: string; key: string;
name: string; name: string;
element: string | null; element: string | null;
@@ -138,7 +133,6 @@ async function mapConcurrent<T, R>(
} }
async function uploadAssets( async function uploadAssets(
jobId: string,
assets: Array<{ assets: Array<{
source?: string; source?: string;
body?: Uint8Array; body?: Uint8Array;
@@ -148,12 +142,7 @@ async function uploadAssets(
): Promise<Set<string>> { ): Promise<Set<string>> {
const storage = await getMediaStorage(); const storage = await getMediaStorage();
let next = 0; let next = 0;
let completed = 0;
const missing = new Set<string>(); const missing = new Set<string>();
await getDb()
.update(catalogSyncJobs)
.set({ total: assets.length, progress: 0 })
.where(eq(catalogSyncJobs.id, jobId));
async function worker() { async function worker() {
while (next < assets.length) { while (next < assets.length) {
const asset = assets[next++]; const asset = assets[next++];
@@ -178,13 +167,6 @@ async function uploadAssets(
}); });
} }
} }
completed += 1;
if (completed % 10 === 0 || completed === assets.length) {
await getDb()
.update(catalogSyncJobs)
.set({ progress: completed })
.where(eq(catalogSyncJobs.id, jobId));
}
} }
} }
await Promise.all( await Promise.all(
@@ -193,40 +175,19 @@ async function uploadAssets(
return missing; return missing;
} }
export async function claimCatalogSyncJob() { export async function processCatalogSync(): Promise<void> {
const [pending] = await getDb() const versionInfo = versionSchema.parse(await getJson(VERSION_URL));
.select() const version = versionInfo.version;
.from(catalogSyncJobs) const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([
.where(eq(catalogSyncJobs.status, "pending"))
.orderBy(asc(catalogSyncJobs.createdAt))
.limit(1);
if (!pending) return null;
const [claimed] = await getDb()
.update(catalogSyncJobs)
.set({ status: "processing", startedAt: new Date(), error: null })
.where(eq(catalogSyncJobs.id, pending.id))
.returning();
return claimed ?? null;
}
export async function processCatalogSyncJob(jobId: string): Promise<void> {
try {
const versionInfo = versionSchema.parse(await getJson(VERSION_URL));
const version = versionInfo.version;
await getDb()
.update(catalogSyncJobs)
.set({ version })
.where(eq(catalogSyncJobs.id, jobId));
const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([
getJson(`${API_ROOT}/${version}/charlist.json`), getJson(`${API_ROOT}/${version}/charlist.json`),
getJson(`${API_ROOT}/${version}/weaponlist.json`), getJson(`${API_ROOT}/${version}/weaponlist.json`),
getJson(`${API_ROOT}/${version}/artifactlist.json`), getJson(`${API_ROOT}/${version}/artifactlist.json`),
]); ]);
const characterData = characterSchema.parse(charactersRaw); const characterData = characterSchema.parse(charactersRaw);
const weaponData = weaponSchema.parse(weaponsRaw); const weaponData = weaponSchema.parse(weaponsRaw);
const artifactData = artifactSchema.parse(artifactsRaw); const artifactData = artifactSchema.parse(artifactsRaw);
const remoteCharacters: SyncedCharacter[] = await mapConcurrent( const remoteCharacters: SyncedCharacter[] = await mapConcurrent(
Object.entries(characterData).filter( Object.entries(characterData).filter(
([, value]) => ([, value]) =>
value.enName && value.enName &&
@@ -246,7 +207,6 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
const nameCardImage = nameCardImageName(value.CardImg); const nameCardImage = nameCardImageName(value.CardImg);
const element = detail?.info?.element ?? value.element ?? null; const element = detail?.info?.element ?? value.element ?? null;
return { return {
version,
key, key,
name: value.enName, name: value.enName,
element, element,
@@ -276,7 +236,7 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
: [], : [],
}; };
}, },
); );
const customCharacters: SyncedCharacter[] = await mapConcurrent( const customCharacters: SyncedCharacter[] = await mapConcurrent(
[...customTravelers], [...customTravelers],
async (traveler) => { async (traveler) => {
@@ -286,7 +246,6 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
), ),
); );
return { return {
version,
key: traveler.key, key: traveler.key,
name: traveler.name, name: traveler.name,
element: traveler.element, element: traveler.element,
@@ -318,12 +277,11 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
value.enName && value.enName !== "???" && value.weaponIcon, value.enName && value.enName !== "???" && value.weaponIcon,
) )
.map(([key, value]) => ({ .map(([key, value]) => ({
version,
key, key,
name: value.enName, name: value.enName,
rarity: rarity(value.qualityType), rarity: rarity(value.qualityType),
weaponType: value.weaponType ?? null, weaponType: value.weaponType ?? null,
imageKey: `catalog/${version}/weapons/${webpName(value.weaponIcon)}`, imageKey: `catalog/weapons/${webpName(value.weaponIcon)}`,
sourceImage: `${API_ROOT}/assets/weaponicon/${encodeURIComponent(webpName(value.weaponIcon))}`, sourceImage: `${API_ROOT}/assets/weaponicon/${encodeURIComponent(webpName(value.weaponIcon))}`,
})); }));
let artifacts = Object.entries(artifactData) let artifacts = Object.entries(artifactData)
@@ -331,11 +289,10 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
([, value]) => value.enName && value.enName !== "???" && value.setIcon, ([, value]) => value.enName && value.enName !== "???" && value.setIcon,
) )
.map(([key, value]) => ({ .map(([key, value]) => ({
version,
key, key,
name: value.enName, name: value.enName,
rarity: rarity(value.qualityType), rarity: rarity(value.qualityType),
imageKey: `catalog/${version}/artifacts/${webpName(value.setIcon)}`, imageKey: `catalog/artifacts/${webpName(value.setIcon)}`,
sourceImage: `${API_ROOT}/assets/artifacts/${encodeURIComponent(webpName(value.setIcon))}`, sourceImage: `${API_ROOT}/assets/artifacts/${encodeURIComponent(webpName(value.setIcon))}`,
})); }));
const uniqueAssets = new Map< const uniqueAssets = new Map<
@@ -398,7 +355,7 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
}); });
} }
} }
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]); const missingAssets = await uploadAssets([...uniqueAssets.values()]);
characters = characters.filter((item) => !missingAssets.has(item.imageKey)); characters = characters.filter((item) => !missingAssets.has(item.imageKey));
characters = characters.map((item) => ({ characters = characters.map((item) => ({
...item, ...item,
@@ -454,10 +411,9 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
{ type: "application/json", acl: "public-read" }, { type: "application/json", acl: "public-read" },
), ),
storage.write( storage.write(
`catalog/${version}/weapons.json`, "catalog/weapons.json",
JSON.stringify( JSON.stringify(
weapons.map((item) => ({ weapons.map((item) => ({
version: item.version,
key: item.key, key: item.key,
name: item.name, name: item.name,
rarity: item.rarity, rarity: item.rarity,
@@ -468,10 +424,9 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
{ type: "application/json", acl: "public-read" }, { type: "application/json", acl: "public-read" },
), ),
storage.write( storage.write(
`catalog/${version}/artifacts.json`, "catalog/artifacts.json",
JSON.stringify( JSON.stringify(
artifacts.map((item) => ({ artifacts.map((item) => ({
version: item.version,
key: item.key, key: item.key,
name: item.name, name: item.name,
rarity: item.rarity, rarity: item.rarity,
@@ -483,32 +438,14 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
]); ]);
await getDb().transaction(async (tx) => { await getDb().transaction(async (tx) => {
await tx await tx.delete(catalogCharacters);
.insert(catalogVersions) await tx.delete(catalogWeapons);
.values({ version, sourceTimestamp: versionInfo.timestamp ?? null }) await tx.delete(catalogArtifacts);
.onConflictDoUpdate({
target: catalogVersions.version,
set: {
sourceTimestamp: versionInfo.timestamp ?? null,
syncedAt: new Date(),
},
});
await tx
.delete(catalogCharacters)
.where(eq(catalogCharacters.version, version));
await tx.delete(catalogCharacterCurrent);
await tx
.delete(catalogWeapons)
.where(eq(catalogWeapons.version, version));
await tx
.delete(catalogArtifacts)
.where(eq(catalogArtifacts.version, version));
if (characters.length) if (characters.length)
await tx await tx
.insert(catalogCharacters) .insert(catalogCharacters)
.values( .values(
characters.map((item) => ({ characters.map((item) => ({
version: item.version,
key: item.key, key: item.key,
name: item.name, name: item.name,
element: item.element, element: item.element,
@@ -522,28 +459,11 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
talents: item.talents, talents: item.talents,
})), })),
); );
if (characters.length)
await tx.insert(catalogCharacterCurrent).values(
characters.map((item) => ({
key: item.key,
name: item.name,
element: item.element,
rarity: item.rarity,
weaponType: item.weaponType,
imageKey: item.imageKey,
elementIconKey: item.elementIconKey,
splashImageKey: item.splashImageKey,
nameCardImageKey: item.nameCardImageKey,
constellations: item.constellations,
talents: item.talents,
})),
);
if (weapons.length) if (weapons.length)
await tx await tx
.insert(catalogWeapons) .insert(catalogWeapons)
.values( .values(
weapons.map((item) => ({ weapons.map((item) => ({
version: item.version,
key: item.key, key: item.key,
name: item.name, name: item.name,
rarity: item.rarity, rarity: item.rarity,
@@ -556,44 +476,15 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
.insert(catalogArtifacts) .insert(catalogArtifacts)
.values( .values(
artifacts.map((item) => ({ artifacts.map((item) => ({
version: item.version,
key: item.key, key: item.key,
name: item.name, name: item.name,
rarity: item.rarity, rarity: item.rarity,
imageKey: item.imageKey, imageKey: item.imageKey,
})), })),
); );
await tx.update(catalogVersions).set({ active: false });
await tx
.update(catalogVersions)
.set({ active: true, syncedAt: new Date() })
.where(eq(catalogVersions.version, version));
await tx
.update(catalogSyncJobs)
.set({
status: "completed",
completedAt: new Date(),
progress: uniqueAssets.size,
total: uniqueAssets.size,
})
.where(eq(catalogSyncJobs.id, jobId));
}); });
await storage.write("catalog/current.json", JSON.stringify({ version }), { await storage.write("catalog/current.json", JSON.stringify({ version }), {
type: "application/json", type: "application/json",
acl: "public-read", acl: "public-read",
}); });
} catch (cause) {
await getDb()
.update(catalogSyncJobs)
.set({
status: "failed",
completedAt: new Date(),
error:
cause instanceof Error
? cause.message.slice(0, 4_000)
: "Unknown sync failure",
})
.where(eq(catalogSyncJobs.id, jobId));
throw cause;
}
} }
+13 -19
View File
@@ -8,9 +8,7 @@ import {
artifactProfiles, artifactProfiles,
artifactRecommendations, artifactRecommendations,
catalogArtifacts, catalogArtifacts,
catalogCharacterCurrent, catalogCharacters,
catalogSyncJobs,
catalogVersions,
catalogWeapons, catalogWeapons,
constellationRecommendations, constellationRecommendations,
extraSectionRows, extraSectionRows,
@@ -33,6 +31,7 @@ import {
updateGuideSchema, updateGuideSchema,
weaponSectionSchema, weaponSectionSchema,
} from "@/lib/guides/schemas"; } from "@/lib/guides/schemas";
import { processCatalogSync } from "@/lib/catalog/sync";
const DEFAULT_SECTIONS = [ const DEFAULT_SECTIONS = [
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 }, { kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
@@ -52,10 +51,8 @@ async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb
export async function createGuide(input: unknown) { export async function createGuide(input: unknown) {
const parsed = createGuideSchema.parse(input); const parsed = createGuideSchema.parse(input);
const db = getDb(); const db = getDb();
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1); const [character] = await db.select().from(catalogCharacters).where(eq(catalogCharacters.key, parsed.characterKey)).limit(1);
if (!version) throw new Error("กรุณา Sync ข้อมูล Lunaris ก่อนสร้างคู่มือ"); if (!character) throw new Error("ไม่พบตัวละครใน Catalog");
const [character] = await db.select().from(catalogCharacterCurrent).where(eq(catalogCharacterCurrent.key, parsed.characterKey)).limit(1);
if (!character) throw new Error("ไม่พบตัวละครใน Catalog เวอร์ชันปัจจุบัน");
const [cover] = await db.select().from(media).where(and(eq(media.id, parsed.coverMediaId), eq(media.status, "ready"))).limit(1); const [cover] = await db.select().from(media).where(and(eq(media.id, parsed.coverMediaId), eq(media.status, "ready"))).limit(1);
if (!cover) throw new Error("กรุณาอัปโหลดภาพ Guide ให้สำเร็จ"); if (!cover) throw new Error("กรุณาอัปโหลดภาพ Guide ให้สำเร็จ");
const baseSlug = slugFromCharacterName(character.name, character.key); const baseSlug = slugFromCharacterName(character.name, character.key);
@@ -65,7 +62,6 @@ export async function createGuide(input: unknown) {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [guide] = await tx.insert(guides).values({ const [guide] = await tx.insert(guides).values({
characterKey: character.key, characterKey: character.key,
catalogVersion: version.version,
slug, slug,
name: character.name, name: character.name,
overview: parsed.overview, overview: parsed.overview,
@@ -97,7 +93,7 @@ export async function saveWeapons(guideId: string, input: unknown) {
return getDb().transaction(async (tx) => { return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId)); await tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId));
if (data.rows.length) await tx.insert(weaponRecommendations).values(data.rows.map((row, sortOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, guideId, catalogVersion: guide.catalogVersion, sortOrder }))); if (data.rows.length) await tx.insert(weaponRecommendations).values(data.rows.map((row, sortOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, guideId, sortOrder })));
return updated; return updated;
}); });
} }
@@ -128,7 +124,7 @@ export async function saveArtifacts(guideId: string, input: unknown) {
await tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId)); await tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId));
for (const [sortOrder, group] of data.groups.entries()) { for (const [sortOrder, group] of data.groups.entries()) {
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning(); const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning();
if (group.rows.length) await tx.insert(artifactRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, percent: row.percent.toString(), groupId: created.id, catalogVersion: guide.catalogVersion, sortOrder: rowOrder }))); if (group.rows.length) await tx.insert(artifactRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, percent: row.percent.toString(), groupId: created.id, sortOrder: rowOrder })));
} }
await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note } }); await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note } });
return updated; return updated;
@@ -154,9 +150,9 @@ export async function saveTeams(guideId: string, input: unknown) {
const weaponKeys = [...new Set(members.map((member) => member.weaponKey).filter((key): key is string => Boolean(key)))]; const weaponKeys = [...new Set(members.map((member) => member.weaponKey).filter((key): key is string => Boolean(key)))];
const artifactKeys = [...new Set(members.map((member) => member.artifactKey).filter((key): key is string => Boolean(key)))]; const artifactKeys = [...new Set(members.map((member) => member.artifactKey).filter((key): key is string => Boolean(key)))];
const [characters, weapons, artifacts] = await Promise.all([ const [characters, weapons, artifacts] = await Promise.all([
characterKeys.length ? getDb().select().from(catalogCharacterCurrent).where(inArray(catalogCharacterCurrent.key, characterKeys)) : [], characterKeys.length ? getDb().select().from(catalogCharacters).where(inArray(catalogCharacters.key, characterKeys)) : [],
weaponKeys.length ? getDb().select().from(catalogWeapons).where(and(eq(catalogWeapons.version, guide.catalogVersion), inArray(catalogWeapons.key, weaponKeys))) : [], weaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, weaponKeys)) : [],
artifactKeys.length ? getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, guide.catalogVersion), inArray(catalogArtifacts.key, artifactKeys))) : [], artifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, artifactKeys)) : [],
]); ]);
if (characters.length !== characterKeys.length) throw new Error("ไม่พบตัวละครบางรายการใน Catalog"); if (characters.length !== characterKeys.length) throw new Error("ไม่พบตัวละครบางรายการใน Catalog");
if (weapons.length !== weaponKeys.length) throw new Error("ไม่พบอาวุธบางรายการใน Catalog"); if (weapons.length !== weaponKeys.length) throw new Error("ไม่พบอาวุธบางรายการใน Catalog");
@@ -173,7 +169,7 @@ export async function saveTeams(guideId: string, input: unknown) {
await tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId)); await tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId));
for (const [sortOrder, team] of data.teams.entries()) { for (const [sortOrder, team] of data.teams.entries()) {
const [created] = await tx.insert(teamCompositions).values({ guideId, title: team.title, rotationSeconds: team.rotationSeconds.toString(), combo: team.combo, sortOrder }).returning(); const [created] = await tx.insert(teamCompositions).values({ guideId, title: team.title, rotationSeconds: team.rotationSeconds.toString(), combo: team.combo, sortOrder }).returning();
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, catalogVersion: guide.catalogVersion, damage: member.damage.toString() }))); await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, damage: member.damage.toString() })));
} }
return updated; return updated;
}); });
@@ -222,9 +218,7 @@ export async function permanentlyDeleteGuide(guideId: string) {
}); });
} }
export async function enqueueCatalogSync(userId: string) { export async function runCatalogSync() {
const [running] = await getDb().select().from(catalogSyncJobs).where(sql`${catalogSyncJobs.status} in ('pending', 'processing')`).limit(1); await processCatalogSync();
if (running) return running; return { status: "saved" as const };
const [job] = await getDb().insert(catalogSyncJobs).values({ triggeredById: userId }).returning();
return job;
} }
+27 -42
View File
@@ -1,6 +1,6 @@
import "server-only"; import "server-only";
import { and, asc, desc, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import { getDb } from "@/db"; import { getDb } from "@/db";
import { import {
@@ -8,9 +8,7 @@ import {
artifactProfiles, artifactProfiles,
artifactRecommendations, artifactRecommendations,
catalogArtifacts, catalogArtifacts,
catalogCharacterCurrent, catalogCharacters,
catalogSyncJobs,
catalogVersions,
catalogWeapons, catalogWeapons,
constellationRecommendations, constellationRecommendations,
extraSectionRows, extraSectionRows,
@@ -23,11 +21,11 @@ import {
} from "@/db/schema"; } from "@/db/schema";
import { publicMediaUrl } from "@/lib/media/storage"; import { publicMediaUrl } from "@/lib/media/storage";
function catalogCharacterWithUrls(item: typeof catalogCharacterCurrent.$inferSelect) { function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) {
return { return {
...item, ...item,
imageUrl: publicMediaUrl(item.imageKey), imageUrl: publicMediaUrl(item.imageKey),
splashImageUrl: item.splashImageKey ? publicMediaUrl(item.splashImageKey) : null, splashImageUrl: item.coopImageKey ? publicMediaUrl(item.coopImageKey) : null,
nameCardImageUrl: item.nameCardImageKey ? publicMediaUrl(item.nameCardImageKey) : null, nameCardImageUrl: item.nameCardImageKey ? publicMediaUrl(item.nameCardImageKey) : null,
elementIconUrl: item.elementIconKey ? publicMediaUrl(item.elementIconKey) : null, elementIconUrl: item.elementIconKey ? publicMediaUrl(item.elementIconKey) : null,
constellations: item.constellations.map((constellation) => ({ constellations: item.constellations.map((constellation) => ({
@@ -43,15 +41,13 @@ function catalogCharacterWithUrls(item: typeof catalogCharacterCurrent.$inferSel
export async function getActiveCatalog() { export async function getActiveCatalog() {
const db = getDb(); const db = getDb();
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1);
if (!version) return null;
const [characters, weapons, artifacts] = await Promise.all([ const [characters, weapons, artifacts] = await Promise.all([
db.select().from(catalogCharacterCurrent).orderBy(asc(catalogCharacterCurrent.name)), db.select().from(catalogCharacters).orderBy(asc(catalogCharacters.name)),
db.select().from(catalogWeapons).where(eq(catalogWeapons.version, version.version)).orderBy(asc(catalogWeapons.name)), db.select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)),
db.select().from(catalogArtifacts).where(eq(catalogArtifacts.version, version.version)).orderBy(asc(catalogArtifacts.name)), db.select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)),
]); ]);
if (!characters.length) return null;
return { return {
version: version.version,
characters: characters.map(catalogCharacterWithUrls), characters: characters.map(catalogCharacterWithUrls),
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })), weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })), artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
@@ -82,31 +78,31 @@ export async function getGuideByCharacterKey(characterKey: string) {
export async function getPublicGuide(slug: string) { export async function getPublicGuide(slug: string) {
const [row] = await getDb() const [row] = await getDb()
.select({ guide: guides, cover: media, character: catalogCharacterCurrent }) .select({ guide: guides, cover: media, character: catalogCharacters })
.from(guides) .from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId)) .innerJoin(media, eq(media.id, guides.coverMediaId))
.innerJoin(catalogCharacterCurrent, eq(catalogCharacterCurrent.key, guides.characterKey)) .innerJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey))
.where(and(eq(guides.slug, slug), isNull(guides.trashedAt))) .where(and(eq(guides.slug, slug), isNull(guides.trashedAt)))
.limit(1); .limit(1);
if (!row) return null; if (!row) return null;
const [sections, weaponRows, groups, profile, constellations, teams] = await Promise.all([ const [sections, weaponRows, groups, profile, constellations, teams] = await Promise.all([
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)), getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
getDb().select({ recommendation: weaponRecommendations, weapon: catalogWeapons }).from(weaponRecommendations).innerJoin(catalogWeapons, and(eq(catalogWeapons.version, weaponRecommendations.catalogVersion), eq(catalogWeapons.key, weaponRecommendations.weaponKey))).where(eq(weaponRecommendations.guideId, row.guide.id)).orderBy(asc(weaponRecommendations.sortOrder)), getDb().select({ recommendation: weaponRecommendations, weapon: catalogWeapons }).from(weaponRecommendations).innerJoin(catalogWeapons, eq(catalogWeapons.key, weaponRecommendations.weaponKey)).where(eq(weaponRecommendations.guideId, row.guide.id)).orderBy(asc(weaponRecommendations.sortOrder)),
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)), getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1), getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
getDb().select().from(constellationRecommendations).where(eq(constellationRecommendations.guideId, row.guide.id)).orderBy(asc(constellationRecommendations.level)), getDb().select().from(constellationRecommendations).where(eq(constellationRecommendations.guideId, row.guide.id)).orderBy(asc(constellationRecommendations.level)),
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)), getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
]); ]);
const artifactRows = groups.length ? await getDb().select({ recommendation: artifactRecommendations, artifact: catalogArtifacts }).from(artifactRecommendations).innerJoin(catalogArtifacts, and(eq(catalogArtifacts.version, artifactRecommendations.catalogVersion), eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey))).where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))).orderBy(asc(artifactRecommendations.sortOrder)) : []; const artifactRows = groups.length ? await getDb().select({ recommendation: artifactRecommendations, artifact: catalogArtifacts }).from(artifactRecommendations).innerJoin(catalogArtifacts, eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey)).where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))).orderBy(asc(artifactRecommendations.sortOrder)) : [];
const secondKeys = artifactRows.map(({ recommendation }) => recommendation.secondArtifactKey).filter((key): key is string => Boolean(key)); const secondKeys = artifactRows.map(({ recommendation }) => recommendation.secondArtifactKey).filter((key): key is string => Boolean(key));
const secondArtifacts = secondKeys.length ? await getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, secondKeys))) : []; const secondArtifacts = secondKeys.length ? await getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, secondKeys)) : [];
const members = teams.length ? await getDb().select({ member: teamMembers, character: catalogCharacterCurrent }).from(teamMembers).innerJoin(catalogCharacterCurrent, eq(catalogCharacterCurrent.key, teamMembers.characterKey)).where(inArray(teamMembers.teamId, teams.map((team) => team.id))).orderBy(asc(teamMembers.position)) : []; const members = teams.length ? await getDb().select({ member: teamMembers, character: catalogCharacters }).from(teamMembers).innerJoin(catalogCharacters, eq(catalogCharacters.key, teamMembers.characterKey)).where(inArray(teamMembers.teamId, teams.map((team) => team.id))).orderBy(asc(teamMembers.position)) : [];
const memberWeaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key)); const memberWeaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key));
const memberArtifactKeys = members.map(({ member }) => member.artifactKey).filter((key): key is string => Boolean(key)); const memberArtifactKeys = members.map(({ member }) => member.artifactKey).filter((key): key is string => Boolean(key));
const [memberWeapons, memberArtifacts] = await Promise.all([ const [memberWeapons, memberArtifacts] = await Promise.all([
memberWeaponKeys.length ? getDb().select().from(catalogWeapons).where(and(eq(catalogWeapons.version, row.guide.catalogVersion), inArray(catalogWeapons.key, memberWeaponKeys))) : [], memberWeaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, memberWeaponKeys)) : [],
memberArtifactKeys.length ? getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, memberArtifactKeys))) : [], memberArtifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, memberArtifactKeys)) : [],
]); ]);
const extraIds = sections.filter((section) => section.kind === "extra").map((section) => section.id); const extraIds = sections.filter((section) => section.kind === "extra").map((section) => section.id);
const extraRows = extraIds.length ? await getDb().select({ row: extraSectionRows, asset: media }).from(extraSectionRows).leftJoin(media, eq(media.id, extraSectionRows.mediaId)).where(inArray(extraSectionRows.sectionId, extraIds)).orderBy(asc(extraSectionRows.sortOrder)) : []; const extraRows = extraIds.length ? await getDb().select({ row: extraSectionRows, asset: media }).from(extraSectionRows).leftJoin(media, eq(media.id, extraSectionRows.mediaId)).where(inArray(extraSectionRows.sectionId, extraIds)).orderBy(asc(extraSectionRows.sortOrder)) : [];
@@ -133,11 +129,6 @@ export async function getPublicGuide(slug: string) {
}; };
} }
export async function getLatestCatalogSyncJob() {
const [job] = await getDb().select().from(catalogSyncJobs).orderBy(desc(catalogSyncJobs.createdAt)).limit(1);
return job ?? null;
}
export async function getPublicTeamExport(teamId: string) { export async function getPublicTeamExport(teamId: string) {
const [row] = await getDb() const [row] = await getDb()
.select({ team: teamCompositions, guide: guides }) .select({ team: teamCompositions, guide: guides })
@@ -156,11 +147,11 @@ export async function getPublicTeamExport(teamId: string) {
if (!row) return null; if (!row) return null;
const members = await getDb() const members = await getDb()
.select({ member: teamMembers, character: catalogCharacterCurrent }) .select({ member: teamMembers, character: catalogCharacters })
.from(teamMembers) .from(teamMembers)
.innerJoin( .innerJoin(
catalogCharacterCurrent, catalogCharacters,
eq(catalogCharacterCurrent.key, teamMembers.characterKey), eq(catalogCharacters.key, teamMembers.characterKey),
) )
.where(eq(teamMembers.teamId, teamId)) .where(eq(teamMembers.teamId, teamId))
.orderBy(asc(teamMembers.position)); .orderBy(asc(teamMembers.position));
@@ -168,8 +159,8 @@ export async function getPublicTeamExport(teamId: string) {
const weaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key)); const weaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key));
const artifactKeys = members.map(({ member }) => member.artifactKey).filter((key): key is string => Boolean(key)); const artifactKeys = members.map(({ member }) => member.artifactKey).filter((key): key is string => Boolean(key));
const [weapons, artifacts] = await Promise.all([ const [weapons, artifacts] = await Promise.all([
weaponKeys.length ? getDb().select().from(catalogWeapons).where(and(eq(catalogWeapons.version, row.guide.catalogVersion), inArray(catalogWeapons.key, weaponKeys))) : [], weaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, weaponKeys)) : [],
artifactKeys.length ? getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, artifactKeys))) : [], artifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, artifactKeys)) : [],
]); ]);
return { return {
@@ -210,10 +201,7 @@ export async function getPublicWeaponExport(guideId: string) {
.from(weaponRecommendations) .from(weaponRecommendations)
.innerJoin( .innerJoin(
catalogWeapons, catalogWeapons,
and( eq(catalogWeapons.key, weaponRecommendations.weaponKey),
eq(catalogWeapons.version, weaponRecommendations.catalogVersion),
eq(catalogWeapons.key, weaponRecommendations.weaponKey),
),
) )
.where(eq(weaponRecommendations.guideId, guideId)) .where(eq(weaponRecommendations.guideId, guideId))
.orderBy(asc(weaponRecommendations.sortOrder)); .orderBy(asc(weaponRecommendations.sortOrder));
@@ -229,7 +217,7 @@ export async function getPublicWeaponExport(guideId: string) {
export async function getPublicArtifactExport(guideId: string) { export async function getPublicArtifactExport(guideId: string) {
const [row] = await getDb() const [row] = await getDb()
.select({ guide: guides, character: catalogCharacterCurrent }) .select({ guide: guides, character: catalogCharacters })
.from(guides) .from(guides)
.innerJoin( .innerJoin(
guideSections, guideSections,
@@ -240,8 +228,8 @@ export async function getPublicArtifactExport(guideId: string) {
), ),
) )
.innerJoin( .innerJoin(
catalogCharacterCurrent, catalogCharacters,
eq(catalogCharacterCurrent.key, guides.characterKey), eq(catalogCharacters.key, guides.characterKey),
) )
.where(and(eq(guides.id, guideId), isNull(guides.trashedAt))) .where(and(eq(guides.id, guideId), isNull(guides.trashedAt)))
.limit(1); .limit(1);
@@ -257,10 +245,7 @@ export async function getPublicArtifactExport(guideId: string) {
.from(artifactRecommendations) .from(artifactRecommendations)
.innerJoin( .innerJoin(
catalogArtifacts, catalogArtifacts,
and( eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey),
eq(catalogArtifacts.version, artifactRecommendations.catalogVersion),
eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey),
),
) )
.where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))) .where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id)))
.orderBy(asc(artifactRecommendations.sortOrder)) .orderBy(asc(artifactRecommendations.sortOrder))
@@ -269,7 +254,7 @@ export async function getPublicArtifactExport(guideId: string) {
.map(({ recommendation }) => recommendation.secondArtifactKey) .map(({ recommendation }) => recommendation.secondArtifactKey)
.filter((key): key is string => Boolean(key)); .filter((key): key is string => Boolean(key));
const secondArtifacts = secondKeys.length const secondArtifacts = secondKeys.length
? await getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, secondKeys))) ? await getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, secondKeys))
: []; : [];
return { return {
-10
View File
@@ -9,7 +9,6 @@ import {
processOutboxEvent, processOutboxEvent,
} from "@/lib/outbox/processor"; } from "@/lib/outbox/processor";
import { closeRedisClient, getRedisClient } from "@/lib/redis/client"; import { closeRedisClient, getRedisClient } from "@/lib/redis/client";
import { claimCatalogSyncJob, processCatalogSyncJob } from "@/lib/catalog/sync";
let stopping = false; let stopping = false;
const stop = () => { const stop = () => {
@@ -41,15 +40,6 @@ async function run(): Promise<void> {
const leaseMs = positiveInteger("OUTBOX_LEASE_MS", 30_000, 300_000); const leaseMs = positiveInteger("OUTBOX_LEASE_MS", 30_000, 300_000);
while (!stopping) { while (!stopping) {
const catalogJob = await claimCatalogSyncJob();
if (catalogJob) {
try {
await processCatalogSyncJob(catalogJob.id);
} catch (cause) {
console.error("Catalog sync failed", cause);
}
continue;
}
const events = await claimOutboxEvents(batchSize, leaseMs); const events = await claimOutboxEvents(batchSize, leaseMs);
if (events.length === 0) { if (events.length === 0) {
await wait(pollMs); await wait(pollMs);