diff --git a/apps/api/test/server.test.ts b/apps/api/test/server.test.ts index ffab4f9..d9cac07 100644 --- a/apps/api/test/server.test.ts +++ b/apps/api/test/server.test.ts @@ -145,6 +145,7 @@ function stubCore( createApiKey: async () => ({ key: "bnb_secret", record: sampleApiKey }), listApiKeys: async () => [sampleApiKey], revokeApiKey: async () => true, + findByUsername: async () => null, ...authOverrides, }, settingsService: { diff --git a/apps/web/src/plugins/shimmie-import/ShimmieImportSection.tsx b/apps/web/src/plugins/shimmie-import/ShimmieImportSection.tsx index e4d96bd..e73f6e9 100644 --- a/apps/web/src/plugins/shimmie-import/ShimmieImportSection.tsx +++ b/apps/web/src/plugins/shimmie-import/ShimmieImportSection.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { shimmieImportApi } from "./client"; @@ -38,15 +39,26 @@ export function ShimmieImportSection() { const [apiKey, setApiKey] = useState(""); const [allUsers, setAllUsers] = useState(true); const [usersList, setUsersList] = useState(""); - const [targetUserId, setTargetUserId] = useState(""); + const [targetUsername, setTargetUsername] = useState(""); const [sourceTimezone, setSourceTimezone] = useState("UTC"); - const [busy, setBusy] = useState<"idle" | "preflight" | "importing">("idle"); + const [busy, setBusy] = useState<"idle" | "preflight" | "importing" | "retrying">("idle"); const [preflight, setPreflight] = useState<{ actingUser: string; maxId: number } | null>(null); const [progress, setProgress] = useState(null); + const [runId, setRunId] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const cancelRef = useRef(false); + // The run the progress panel currently describes — read via ref so `onRetry`'s + // async loop can't act on a stale closure when deciding whether to update it. + const activeRunIdRef = useRef(null); + + const queryClient = useQueryClient(); + const runsQuery = useQuery({ + queryKey: ["shimmie-import", "runs"], + queryFn: async () => call(await importApi.runs.get()), + }); + const refreshRuns = () => queryClient.invalidateQueries({ queryKey: ["shimmie-import", "runs"] }); // Stop the import loop if the component unmounts (leaving /admin), so it doesn't // keep hitting the API or setting state on an unmounted component. @@ -93,17 +105,12 @@ export function ShimmieImportSection() { setError("List at least one shimmie username, or choose “all users”."); return; } - const targetId = targetUserId.trim() ? Number(targetUserId.trim()) : undefined; - if (targetId !== undefined && (!Number.isInteger(targetId) || targetId < 1)) { - setError("Target user id must be a positive integer (or blank for yourself)."); - return; - } const started = await call( await importApi.runs.post({ baseUrl, apiKey, users, - targetUserId: targetId, + targetUsername: targetUsername.trim() || undefined, sourceTimezone: sourceTimezone.trim() || "UTC", }), ); @@ -111,14 +118,16 @@ export function ShimmieImportSection() { setError(started.error); return; } - const runId = started.runId; + const activeRunId = started.runId; + setRunId(activeRunId); + activeRunIdRef.current = activeRunId; for (;;) { if (cancelRef.current) { - await importApi.runs({ id: runId }).cancel.post(); + await importApi.runs({ id: activeRunId }).cancel.post(); setNotice("Import canceled."); break; } - const step = await call(await importApi.runs({ id: runId }).step.post({ apiKey })); + const step = await call(await importApi.runs({ id: activeRunId }).step.post({ apiKey })); // Bail after the await if we were unmounted / canceled mid-request. if (cancelRef.current) break; if (!step.ok) { @@ -142,6 +151,51 @@ export function ShimmieImportSection() { setError("Import request failed (are you signed in as an admin?)."); } finally { setBusy("idle"); + void refreshRuns(); + } + } + + async function onRetry(targetRunId: number) { + if (busy !== "idle") return; + if (!apiKey) { + setError("Enter the shimmie API key to retry."); + return; + } + resetMessages(); + setBusy("retrying"); + cancelRef.current = false; + try { + for (;;) { + if (cancelRef.current) break; + const res = await call(await importApi.runs({ id: targetRunId })["retry-failed"].post({ apiKey })); + if (cancelRef.current) break; + if (!res.ok) { + setError(res.error); + break; + } + // Only touch the progress panel when retrying the run it describes — a + // history-row retry of a different run must not overwrite it. + if (targetRunId === activeRunIdRef.current) { + setProgress((prev) => + prev ? { ...prev, imported: prev.imported + res.recovered, failed: res.remainingFailed } : prev, + ); + } + // Stop when nothing's left OR this batch made no progress — otherwise a + // set of permanently-failing posts (re-selected each call) would loop forever. + if (res.remainingFailed === 0 || res.recovered === 0) { + setNotice( + res.remainingFailed === 0 + ? "Retry complete — all recovered." + : `Retry stopped · ${res.remainingFailed} still failing.`, + ); + break; + } + } + } catch { + setError("Retry request failed (are you signed in as an admin?)."); + } finally { + setBusy("idle"); + void refreshRuns(); } } @@ -216,13 +270,13 @@ export function ShimmieImportSection() { @@ -267,7 +321,7 @@ export function ShimmieImportSection() { {busy === "importing" ?