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_importaudit entries from the last seven days. - Use the existing
ASSETS_IMPORT-protected endpoint andadminFetchCSRF 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, andrequestRecentFurniResync(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 furniaction, 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.