diff --git a/src/app/admin/import/audit/audit-client.tsx b/src/app/admin/import/audit/audit-client.tsx new file mode 100644 index 00000000..ced701d7 --- /dev/null +++ b/src/app/admin/import/audit/audit-client.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { + AlertCircle, + CheckCircle2, + ClipboardList, + ExternalLink, + Loader2, + SearchX, +} from "lucide-react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { adminFetch } from "@/lib/admin-fetch"; + +interface AuditIssue { + type: string; + severity: "error" | "warning" | "info"; + classname?: string; + itemId?: number; + catalogId?: number; + message: string; + sourceName?: string; +} + +interface MissingFromSource { + sourceName: string; + sourceId: string; + classname: string; + itemName: string; +} + +interface AuditResult { + issues: AuditIssue[]; + missingFromSources: MissingFromSource[]; + summary: { + totalItemsBase: number; + totalCatalogItems: number; + missingCatalog: number; + orphanedCatalog: number; + missingNitro: number; + missingIcon: number; + duplicates: number; + missingFromSourcesTotal: number; + }; +} + +const ICON_COLORS: Record = { + error: "text-[var(--admin-error)]", + warning: "text-[var(--admin-warning)]", + info: "text-[var(--admin-text-muted)]", +}; + +const TYPE_LABELS: Record = { + missing_catalog: "No catalog entry", + orphaned_catalog: "Orphaned catalog reference", + missing_nitro: "Missing .nitro file", + missing_icon: "Missing icon file", + duplicate_classname: "Duplicate classname", + source_fetch_failed: "Source fetch failed", + sources_list_failed: "Sources list failed", +}; + +export function AuditClient() { + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [progress, setProgress] = useState(""); + const [activeTab, setActiveTab] = useState<"issues" | "sources">("issues"); + + const runAudit = useCallback(async () => { + setLoading(true); + setResult(null); + setProgress("Starting audit…"); + + try { + const res = await adminFetch("/api/admin/import/audit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + toast.error(data.error || "Audit failed"); + setProgress(""); + setLoading(false); + return; + } + + const data = await res.json(); + setResult(data); + toast.success("Audit complete"); + } catch { + toast.error("Audit request failed"); + } finally { + setLoading(false); + setProgress(""); + } + }, []); + + const hasIssues = result && result.issues.length > 0; + const errors = result?.issues.filter((i) => i.severity === "error") ?? []; + const warnings = result?.issues.filter((i) => i.severity === "warning") ?? []; + + return ( +
+ {!result && ( + + )} + + {result && ( + <> + {/* Summary cards */} +
+
+
+ {result.summary.totalItemsBase} +
+
+ items_base entries +
+
+
+
+ {result.summary.totalCatalogItems} +
+
+ catalog_items entries +
+
+
0 ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" : "bg-card"}`} + > +
0 ? "text-[var(--admin-error)]" : ""}`} + > + {errors.length} +
+
Errors
+
+
0 ? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]" : "bg-card"}`} + > +
0 ? "text-[var(--admin-warning)]" : ""}`} + > + {warnings.length} +
+
Warnings
+
+
+ + {/* Quick stats */} +
+
+ {result.summary.missingCatalog > 0 ? ( + + ) : ( + + )} + + {result.summary.missingCatalog} items without catalog entry + +
+
+ {result.summary.orphanedCatalog > 0 ? ( + + ) : ( + + )} + + {result.summary.orphanedCatalog} orphaned catalog references + +
+
+ {result.summary.missingNitro > 0 ? ( + + ) : ( + + )} + {result.summary.missingNitro} items without .nitro +
+
+ {result.summary.missingIcon > 0 ? ( + + ) : ( + + )} + {result.summary.missingIcon} items without icon +
+
+ {result.summary.duplicates > 0 ? ( + + ) : ( + + )} + {result.summary.duplicates} duplicate classnames +
+
+ {result.summary.missingFromSourcesTotal > 0 ? ( + + ) : ( + + )} + + {result.summary.missingFromSourcesTotal} items missing from + clone sources + +
+
+ + {/* Tabs */} + {hasIssues || result.missingFromSources.length > 0 ? ( +
+
+ + +
+
+ ) : null} + + {/* Issues list */} + {activeTab === "issues" && hasIssues && ( +
+ {result.issues.map((issue) => ( +
+
+ +
+
+ + {TYPE_LABELS[issue.type] || issue.type} + + {issue.classname && ( + + {issue.classname} + + )} + {issue.sourceName && ( + + source: {issue.sourceName} + + )} +
+

{issue.message}

+
+
+
+ ))} +
+ )} + + {/* Missing from sources list */} + {activeTab === "sources" && result.missingFromSources.length > 0 && ( +
+ {result.missingFromSources.map((item) => ( +
+ + {item.classname} + + — {item.itemName} + + + in {item.sourceName} + +
+ ))} +
+ )} + + {activeTab === "issues" && + !hasIssues && + result.missingFromSources.length === 0 && ( +
+ +

No issues found

+

Your catalog is clean!

+
+ )} + + + + )} + + {!loading && !result && ( +
+ +

+ Run a full audit to check for missing catalog entries, broken + references, missing files, and items available from clone sources. +

+
+ )} +
+ ); +} diff --git a/src/app/admin/import/audit/page.tsx b/src/app/admin/import/audit/page.tsx new file mode 100644 index 00000000..6a930f4d --- /dev/null +++ b/src/app/admin/import/audit/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { AuditClient } from "./audit-client"; + +export default async function AuditPage() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { + redirect("/admin"); + } + + return ( +
+
+

Catalog Audit

+

+ Scan your entire furniture catalog for missing entries, broken + references, missing files, and items available from clone sources. +

+
+ +
+ ); +} diff --git a/src/app/admin/import/page.tsx b/src/app/admin/import/page.tsx index 7399af21..ed2a61ac 100644 --- a/src/app/admin/import/page.tsx +++ b/src/app/admin/import/page.tsx @@ -1,6 +1,7 @@ import { BadgeCheck, Cat, + ClipboardCheck, Copy, Dumbbell, ImageIcon, @@ -76,6 +77,12 @@ export default async function ImportPage() { icon: ImageIcon, description: t("repairIconsDescription"), }, + { + href: "/admin/import/audit", + label: t("audit"), + icon: ClipboardCheck, + description: t("auditDescription"), + }, ] as const; return ( diff --git a/src/app/api/admin/import/audit/route.ts b/src/app/api/admin/import/audit/route.ts new file mode 100644 index 00000000..6fd3e197 --- /dev/null +++ b/src/app/api/admin/import/audit/route.ts @@ -0,0 +1,13 @@ +import { withAdmin } from "@/lib/api-handler"; +import { apiError, apiOk } from "@/lib/api-response"; +import { PERMS } from "@/lib/permissions"; +import { runCatalogAudit } from "@/lib/services/catalog-audit"; + +export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { + try { + const result = await runCatalogAudit(); + return apiOk(result as unknown as Record); + } catch (err) { + return apiError(`Audit failed: ${(err as Error).message}`, 500); + } +}); diff --git a/src/lib/services/catalog-audit.ts b/src/lib/services/catalog-audit.ts new file mode 100644 index 00000000..4e2de83f --- /dev/null +++ b/src/lib/services/catalog-audit.ts @@ -0,0 +1,214 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { prisma } from "@/lib/prisma"; +import { fetchSourceFurnidata } from "@/lib/services/clone-import"; +import { listSources } from "@/lib/services/clone-sources"; +import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; +import { ensureDirectories } from "./furni-import"; + +export interface AuditIssue { + type: string; + severity: "error" | "warning" | "info"; + classname?: string; + itemId?: number; + catalogId?: number; + message: string; + sourceName?: string; +} + +export interface MissingFromSource { + sourceName: string; + sourceId: string; + classname: string; + itemName: string; +} + +export interface AuditResult { + issues: AuditIssue[]; + missingFromSources: MissingFromSource[]; + summary: { + totalItemsBase: number; + totalCatalogItems: number; + missingCatalog: number; + orphanedCatalog: number; + missingNitro: number; + missingIcon: number; + duplicates: number; + missingFromSourcesTotal: number; + }; +} + +export async function runCatalogAudit( + onProgress?: (msg: string) => void, +): Promise { + const issues: AuditIssue[] = []; + const missingFromSources: MissingFromSource[] = []; + + onProgress?.("Loading directories…"); + await ensureDirectories(); + const { iconDir, nitroDir } = await getFurniAssetDirs(); + + onProgress?.("Loading items_base…"); + const items = await prisma.$queryRaw< + Array<{ id: number; item_name: string; public_name: string; type: string }> + >`SELECT id, item_name, public_name, type FROM items_base ORDER BY id`; + + onProgress?.("Loading catalog_items…"); + const catalogItems = await prisma.$queryRaw< + Array<{ id: number; item_ids: string; catalog_name: string }> + >`SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id`; + + const summary = { + totalItemsBase: items.length, + totalCatalogItems: catalogItems.length, + missingCatalog: 0, + orphanedCatalog: 0, + missingNitro: 0, + missingIcon: 0, + duplicates: 0, + missingFromSourcesTotal: 0, + }; + + // Build lookup: set of items_base IDs that have catalog entries + const itemIdsWithCatalog = new Set(); + const itemNamesWithCatalog = new Set(); + for (const ci of catalogItems) { + const ids = ci.item_ids.split(";").map(Number).filter(Boolean); + for (const id of ids) { + itemIdsWithCatalog.add(id); + } + itemNamesWithCatalog.add(ci.catalog_name); + } + + // Build items_base lookup by id and name + const itemById = new Map(); + const itemByName = new Map(); + const classnameCount = new Map(); + + for (const item of items) { + itemById.set(item.id, item); + itemByName.set(item.item_name, item); + classnameCount.set( + item.item_name, + (classnameCount.get(item.item_name) ?? 0) + 1, + ); + } + + onProgress?.("Checking items_base entries…"); + for (const item of items) { + if (!itemIdsWithCatalog.has(item.id)) { + summary.missingCatalog++; + issues.push({ + type: "missing_catalog", + severity: "warning", + classname: item.item_name, + itemId: item.id, + message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`, + }); + } + } + + onProgress?.("Checking for duplicate classnames…"); + for (const [name, count] of classnameCount) { + if (count > 1) { + summary.duplicates++; + issues.push({ + type: "duplicate_classname", + severity: "error", + classname: name, + message: `"${name}" appears ${count} times in items_base`, + }); + } + } + + onProgress?.("Checking catalog_items for orphaned references…"); + for (const ci of catalogItems) { + const ids = ci.item_ids.split(";").map(Number).filter(Boolean); + for (const id of ids) { + if (!itemById.has(id)) { + summary.orphanedCatalog++; + issues.push({ + type: "orphaned_catalog", + severity: "error", + catalogId: ci.id, + message: `catalog_items ID ${ci.id} ("${ci.catalog_name}") references items_base ID ${id} which does not exist`, + }); + } + } + } + + onProgress?.("Checking .nitro files…"); + for (const item of items) { + const nitroPath = path.join(nitroDir, `${item.item_name}.nitro`); + if (!existsSync(nitroPath)) { + summary.missingNitro++; + issues.push({ + type: "missing_nitro", + severity: "error", + classname: item.item_name, + itemId: item.id, + message: `"${item.item_name}" (ID ${item.id}) has no .nitro file`, + }); + } + } + + onProgress?.("Checking icon files…"); + for (const item of items) { + const iconPath = path.join(iconDir, `${item.item_name}_icon.png`); + if (!existsSync(iconPath)) { + summary.missingIcon++; + issues.push({ + type: "missing_icon", + severity: "warning", + classname: item.item_name, + itemId: item.id, + message: `"${item.item_name}" (ID ${item.id}) has no icon file`, + }); + } + } + + onProgress?.("Comparing with clone sources…"); + try { + const sources = await listSources(); + for (const source of sources) { + try { + const entries = await fetchSourceFurnidata(source.furnidataUrl); + for (const entry of entries) { + if (!itemByName.has(entry.classname)) { + missingFromSources.push({ + sourceName: source.name, + sourceId: source.id, + classname: entry.classname, + itemName: entry.name, + }); + } + } + } catch { + issues.push({ + type: "source_fetch_failed", + severity: "error", + sourceName: source.name, + message: `Failed to fetch furnidata from "${source.name}"`, + }); + } + } + } catch (err) { + issues.push({ + type: "sources_list_failed", + severity: "error", + message: `Failed to list clone sources: ${(err as Error).message}`, + }); + } + + summary.missingFromSourcesTotal = missingFromSources.length; + + // Sort issues: errors first, then by type + issues.sort((a, b) => { + const sev = { error: 0, warning: 1, info: 2 }; + const diff = (sev[a.severity] ?? 0) - (sev[b.severity] ?? 0); + if (diff !== 0) return diff; + return a.type.localeCompare(b.type); + }); + + return { issues, missingFromSources, summary }; +} diff --git a/src/messages/en.json b/src/messages/en.json index 7e4aa449..3c091243 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2494,7 +2494,9 @@ "sync": "Auto Sync", "syncDescription": "Automatically import missing furniture from all configured sources.", "repairIcons": "Repair Icons", - "repairIconsDescription": "Download any missing icon files for furniture already in the database." + "repairIconsDescription": "Download any missing icon files for furniture already in the database.", + "audit": "Catalog Audit", + "auditDescription": "Scan your catalog for missing entries, broken references, and items missing from clone sources." }, "translations": { "title": "Translations",