Files
EpicNext-Cms/docs/superpowers/plans/2026-08-02-manual-recent-furni-resync.md
T

11 KiB

Manual Recent Furni Resync Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a guarded Tools command to Admin Import Furni that resyncs furniture imported during the last seven days and displays the operation result.

Architecture: Keep the existing permission-protected /api/admin/import/furni/resync route unchanged. Add a client-safe request/normalization helper with an injected fetcher so its exact URL, HTTP method, success payload, partial failures, and transport failures are testable without rendering the large Import Furni client. The existing client component owns the confirmation, loading, and result UI state.

Tech Stack: TypeScript, React 19, Next.js 16 App Router, Vitest, shadcn/Radix UI, Sonner, Biome.

Global Constraints

  • The operation targets only furni_import audit entries from the last seven days.
  • Use the existing ASSETS_IMPORT-protected endpoint and adminFetch CSRF flow.
  • Do not expose all=1, delete database rows, or regenerate .nitro, SWF, or icon files.
  • Display examined, resynced, failed, RCON status, and returned per-item errors.
  • Prevent duplicate submissions while a request is active.

Task 1: Tested recent-resync client contract

Files:

  • Create: src/lib/admin/recent-furni-resync.ts
  • Test: src/lib/admin/recent-furni-resync.test.ts

Interfaces:

  • Consumes: a fetcher matching (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>.

  • Produces: RECENT_FURNI_RESYNC_URL, RecentFurniResyncError, RecentFurniResyncResult, and requestRecentFurniResync(fetcher): Promise<RecentFurniResyncResult>.

  • Step 1: Write the failing request-contract tests

Create src/lib/admin/recent-furni-resync.test.ts:

import { describe, expect, it, vi } from "vitest";
import {
	RECENT_FURNI_RESYNC_URL,
	requestRecentFurniResync,
} from "./recent-furni-resync";

describe("requestRecentFurniResync", () => {
	it("posts to the fixed seven-day resync endpoint and normalizes the result", async () => {
		const fetcher = vi.fn(async () =>
			Response.json({
				ok: true,
				mode: "days",
				days: 7,
				examined: 4,
				resynced: 3,
				failed: 1,
				rconOk: false,
				errors: [{ classname: "chair", message: "invalid entry" }],
			}),
		);

		await expect(requestRecentFurniResync(fetcher)).resolves.toEqual({
			examined: 4,
			resynced: 3,
			failed: 1,
			rconOk: false,
			errors: [{ classname: "chair", message: "invalid entry" }],
		});
		expect(RECENT_FURNI_RESYNC_URL).toBe(
			"/api/admin/import/furni/resync?days=7",
		);
		expect(fetcher).toHaveBeenCalledWith(RECENT_FURNI_RESYNC_URL, {
			method: "POST",
		});
	});

	it("throws the API error when the request fails", async () => {
		const fetcher = vi.fn(async () =>
			Response.json({ error: "Forbidden" }, { status: 403 }),
		);

		await expect(requestRecentFurniResync(fetcher)).rejects.toThrow(
			"Forbidden",
		);
	});
});
  • Step 2: Run the tests and verify RED

Run:

pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts

Expected: FAIL because recent-furni-resync.ts does not exist.

  • Step 3: Implement the minimal typed request helper

Create src/lib/admin/recent-furni-resync.ts:

export const RECENT_FURNI_RESYNC_URL =
	"/api/admin/import/furni/resync?days=7";

export interface RecentFurniResyncError {
	classname: string;
	message: string;
}

export interface RecentFurniResyncResult {
	examined: number;
	resynced: number;
	failed: number;
	rconOk: boolean;
	errors: RecentFurniResyncError[];
}

type AdminFetcher = (
	input: RequestInfo | URL,
	init?: RequestInit,
) => Promise<Response>;

export async function requestRecentFurniResync(
	fetcher: AdminFetcher,
): Promise<RecentFurniResyncResult> {
	const response = await fetcher(RECENT_FURNI_RESYNC_URL, { method: "POST" });
	const payload = (await response.json()) as Record<string, unknown>;
	if (!response.ok) {
		throw new Error(
			typeof payload.error === "string" ? payload.error : "Furni resync failed",
		);
	}

	const errors = Array.isArray(payload.errors)
		? payload.errors.filter(
				(value): value is RecentFurniResyncError =>
					typeof value === "object" &&
					value !== null &&
					typeof (value as RecentFurniResyncError).classname === "string" &&
					typeof (value as RecentFurniResyncError).message === "string",
			)
		: [];

	return {
		examined: Number(payload.examined) || 0,
		resynced: Number(payload.resynced) || 0,
		failed: Number(payload.failed) || 0,
		rconOk: payload.rconOk === true,
		errors,
	};
}
  • Step 4: Run focused tests and verify GREEN

Run:

pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts
pnpm exec biome check --write src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts

Expected: 2 tests pass and Biome reports no remaining errors.

  • Step 5: Commit the tested contract
git add -- src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
git commit -m "feat: add recent furni resync client contract"

Task 2: Import Furni Tools action and result UI

Files:

  • Modify: src/app/admin/import/furni/import-furni-client.tsx
  • Consume: src/lib/admin/recent-furni-resync.ts

Interfaces:

  • Consumes: requestRecentFurniResync(adminFetch): Promise<RecentFurniResyncResult>.

  • Produces: a Tools → Update imported furni action, confirmation dialog, loading state, and dismissible result panel.

  • Step 1: Add state and the guarded request handler

Import DatabaseZap, RecentFurniResyncResult, and requestRecentFurniResync. Add state alongside the existing reorganization state:

const [confirmRecentResync, setConfirmRecentResync] = useState(false);
const [recentResyncing, setRecentResyncing] = useState(false);
const [recentResyncResult, setRecentResyncResult] =
	useState<RecentFurniResyncResult | null>(null);

Add the handler near startReorganize:

async function resyncRecentImports() {
	setConfirmRecentResync(false);
	setRecentResyncing(true);
	setRecentResyncResult(null);
	try {
		const result = await requestRecentFurniResync(adminFetch);
		setRecentResyncResult(result);
		if (result.examined === 0) {
			toast.info("No imported furni found in the last 7 days");
		} else if (result.failed > 0 || !result.rconOk) {
			toast.warning("Furni resync completed with warnings");
		} else {
			toast.success(`Updated ${result.resynced} imported furni`);
		}
		fetchStats();
	} catch (error) {
		toast.error(
			error instanceof Error ? error.message : "Furni resync failed",
		);
	} finally {
		setRecentResyncing(false);
	}
}
  • Step 2: Add the Tools entry and duplicate-submit guard

Insert before the Tools separator:

<DropdownMenuItem
	onClick={() => setConfirmRecentResync(true)}
	disabled={recentResyncing}
>
	{recentResyncing ? (
		<Loader2 className="mr-2 h-4 w-4 animate-spin" />
	) : (
		<DatabaseZap className="mr-2 h-4 w-4" />
	)}
	{recentResyncing ? "Updating imported furni..." : "Update imported furni"}
</DropdownMenuItem>
  • Step 3: Add the confirmation dialog

Add a controlled dialog beside the existing batch confirmation dialogs:

<Dialog open={confirmRecentResync} onOpenChange={setConfirmRecentResync}>
	<DialogContent className="max-w-sm">
		<DialogHeader>
			<DialogTitle>Update imported furni?</DialogTitle>
			<DialogDescription>
				This updates FurnitureData for furni imported during the last 7 days,
				then refreshes the emulator catalog and item caches. No database rows or
				asset files are deleted.
			</DialogDescription>
		</DialogHeader>
		<DialogFooter>
			<Button variant="outline" onClick={() => setConfirmRecentResync(false)}>
				Cancel
			</Button>
			<Button onClick={resyncRecentImports} disabled={recentResyncing}>
				Update last 7 days
			</Button>
		</DialogFooter>
	</DialogContent>
</Dialog>
  • Step 4: Render a dismissible result panel

Place the panel after the existing error banner and before batch summaries:

{recentResyncResult && (
	<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">Imported Furni Update</h3>
			<Button
				variant="ghost"
				size="sm"
				onClick={() => setRecentResyncResult(null)}
			>
				Dismiss
			</Button>
		</div>
		<div className="flex flex-wrap gap-4 text-sm">
			<span>{recentResyncResult.examined} examined</span>
			<span className="text-[var(--admin-success)]">
				{recentResyncResult.resynced} updated
			</span>
			<span className={recentResyncResult.failed ? "text-destructive" : ""}>
				{recentResyncResult.failed} failed
			</span>
			<span
				className={
					recentResyncResult.rconOk
						? "text-[var(--admin-success)]"
						: "text-[var(--admin-warning)]"
				}
			>
				RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"}
			</span>
		</div>
		{recentResyncResult.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">
					{recentResyncResult.errors.map((error) => (
						<p key={`${error.classname}:${error.message}`}>
							<span className="font-mono">{error.classname}</span>: {error.message}
						</p>
					))}
				</div>
			</details>
		)}
	</div>
)}
  • Step 5: Run focused and static verification
pnpm exec biome check --write src/app/admin/import/furni/import-furni-client.tsx src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts src/lib/services/furni-data-paths.test.ts
pnpm typecheck

Expected: Biome clean, focused tests pass, and TypeScript exits 0.

  • Step 6: Run complete regression verification
pnpm test
$env:NODE_ENV='production'
$env:DATABASE_URL='mysql://test:test@localhost:3306/test?charset=utf8mb4'
$env:AUTH_SECRET='ci-test-secret-key-that-is-long-enough'
pnpm build

Expected: all tests pass and the production build exits 0. Redis/database availability warnings during static generation are acceptable in the local test environment; compilation or route-generation errors are not.

  • Step 7: Commit the UI integration
git add -- src/app/admin/import/furni/import-furni-client.tsx
git commit -m "feat: add manual recent furni resync action"
  • Step 8: Verify the final repository state
git status --short
git log --oneline origin/main..HEAD

Expected: clean worktree and the design, plan, tested helper, and UI commits are ahead of origin/main, ready for an explicit push request.