feat: add manual 'verify & fix all interactions' admin tool

New POST /api/admin/import/furni/verify re-runs the full interaction
verification over items_base at any time, plus a Tools dropdown entry
in the furni import admin. It corrects interaction_type,
interaction_modes_count and allow_sit/lay/walk against real furniture
data (furnidata flags + .nitro animation states); items without real
data are skipped and existing emulator handler types are preserved.
This commit is contained in:
openhands committed 2026-08-19 21:11:38 +02:00
1 parent 0b1d3b24c2
commit 2b9a015afb
3 files changed
+151

No files matched your search

@@ -58,6 +58,10 @@ import {
type RecentFurniResyncResult,
requestRecentFurniResync,
} from "@/lib/admin/recent-furni-resync";
import {
requestVerifyInteractions,
type VerifyInteractionsResult,
} from "@/lib/admin/verify-interactions";
import { adminFetch } from "@/lib/admin-fetch";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import {
@@ -142,6 +146,9 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
const [recentResyncing, setRecentResyncing] = useState(false);
const [recentResyncResult, setRecentResyncResult] =
useState<RecentFurniResyncResult | null>(null);
const [verifyingInteractions, setVerifyingInteractions] = useState(false);
const [verifyResult, setVerifyResult] =
useState<VerifyInteractionsResult | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const batchAbortRef = useRef<AbortController | null>(null);
@@ -739,6 +746,35 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
}
}
async function verifyAllInteractions() {
setVerifyingInteractions(true);
setVerifyResult(null);
try {
const result = await requestVerifyInteractions(adminFetch);
setVerifyResult(result);
if (result.fixed === 0 && result.errors.length === 0) {
toast.success(
`Checked ${result.checked.toLocaleString()} items — all consistent`,
);
} else if (result.errors.length > 0) {
toast.warning(
`Verified ${result.checked.toLocaleString()} items, ${result.errors.length} errors`,
);
} else {
toast.success(`Fixed ${result.fixed} items from real furniture data`);
}
fetchStats();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Interaction verification failed",
);
} finally {
setVerifyingInteractions(false);
}
}
async function applyReorganize() {
setReorgPreview(null);
setReorganizing(true);
@@ -957,6 +993,50 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
</div>
)}
{verifyResult && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">Interaction Verification</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setVerifyResult(null)}
>
Dismiss
</Button>
</div>
<div className="flex flex-wrap gap-4 text-sm">
<span>{verifyResult.checked.toLocaleString()} checked</span>
<span
className={
verifyResult.fixed > 0
? "text-[var(--admin-warning)]"
: "text-[var(--admin-success)]"
}
>
{verifyResult.fixed} fixed
</span>
<span className="text-muted-foreground">
{verifyResult.skipped.toLocaleString()} skipped (no real data)
</span>
</div>
{verifyResult.errors.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">
View errors
</summary>
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
{verifyResult.errors.map((error) => (
<p key={error}>
<span className="font-mono">{error}</span>
</p>
))}
</div>
</details>
)}
</div>
)}
{/* Batch Summary */}
{batchSummary && (
<div className="rounded-lg border p-4 space-y-3 bg-card">
@@ -1471,6 +1551,19 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
<FolderSync className="w-4 h-4 mr-2" />
{reorganizing ? "Reorganizing..." : "Reorganize Catalog"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={verifyAllInteractions}
disabled={verifyingInteractions}
>
{verifyingInteractions ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCheck className="mr-2 h-4 w-4" />
)}
{verifyingInteractions
? "Verifying interactions..."
: "Verify & fix all interactions"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={cleanSwf} disabled={cleaningSwf}>
<Trash2 className="w-4 h-4 mr-2" />
@@ -0,0 +1,18 @@
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { verifyAndFixInteractionModesCount } from "@/lib/services/furni-import";
/**
* POST /api/admin/import/furni/verify
*
* Re-runs the interaction verification over the ENTIRE items_base table,
* correcting interaction_type, interaction_modes_count and allow_sit/
* allow_lay/allow_walk against the real furniture data (furnidata flags +
* .nitro animation states). Only items with authoritative data are touched —
* no keyword guesses, so no false positives.
*/
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
const result = await verifyAndFixInteractionModesCount();
return apiOk(result);
});
+40
View File
@@ -0,0 +1,40 @@
export const VERIFY_INTERACTIONS_URL = "/api/admin/import/furni/verify";
export interface VerifyInteractionsResult {
fixed: number;
checked: number;
skipped: number;
errors: string[];
}
type AdminFetcher = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export async function requestVerifyInteractions(
fetcher: AdminFetcher,
): Promise<VerifyInteractionsResult> {
const response = await fetcher(VERIFY_INTERACTIONS_URL, { method: "POST" });
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(
typeof payload.error === "string"
? payload.error
: "Interaction verification failed",
);
}
const errors = Array.isArray(payload.errors)
? payload.errors.filter(
(value): value is string => typeof value === "string",
)
: [];
return {
fixed: Number(payload.fixed) || 0,
checked: Number(payload.checked) || 0,
skipped: Number(payload.skipped) || 0,
errors,
};
}