docs: plan manual recent furni resync
This commit is contained in:
1 parent
6b6fc5dc64
commit
cf563f3550
1 file changed
+357
@@ -0,0 +1,357 @@
|
||||
# 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`:
|
||||
|
||||
```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:
|
||||
|
||||
```powershell
|
||||
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`:
|
||||
|
||||
```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:
|
||||
|
||||
```powershell
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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:
|
||||
|
||||
```ts
|
||||
const [confirmRecentResync, setConfirmRecentResync] = useState(false);
|
||||
const [recentResyncing, setRecentResyncing] = useState(false);
|
||||
const [recentResyncResult, setRecentResyncResult] =
|
||||
useState<RecentFurniResyncResult | null>(null);
|
||||
```
|
||||
|
||||
Add the handler near `startReorganize`:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```tsx
|
||||
<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:
|
||||
|
||||
```tsx
|
||||
<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:
|
||||
|
||||
```tsx
|
||||
{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**
|
||||
|
||||
```powershell
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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.
|
||||
Reference in new issue
Block a user