feat(cms): recover drafts and failed imports with verified UI workflows
CI / check (push) Successful in 2m3s
CI / deploy (push) Successful in 1m31s
CI / publish-container (push) Successful in 1m41s

This commit is contained in:
Simo committed 2026-09-11 08:58:10 +02:00
1 parent 410a1e466c
commit 88790d1a0d
84 files changed
+3952 -288

No files matched your search

@@ -0,0 +1,164 @@
import { QueryClient, queryOptions } from "@tanstack/react-query";
import { z } from "zod/mini";
import { adminFetch } from "@/lib/admin-fetch";
import type { ImportJob } from "@/lib/furni/import-job";
const endpoint = "/api/admin/studio/import-jobs";
const historyTimeoutMs = 15_000;
const jobSchema = z.looseObject({
id: z.uuid(),
userId: z.int().check(z.positive()),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
state: z.enum(["queued", "running", "completed", "interrupted", "cancelled"]),
translate: z.boolean(),
mode: z.optional(z.literal("repair")),
syncKind: z.optional(z.enum(["official", "clone"])),
operationId: z.optional(z.string()),
retryOf: z.optional(z.string()),
sourceId: z.optional(z.string()),
cancelRequested: z.optional(z.boolean()),
langs: z.optional(z.array(z.string())),
items: z.array(
z.looseObject({
id: z.int().check(z.nonnegative()),
classname: z.string(),
name: z.string(),
description: z.string(),
type: z.string(),
revision: z.number(),
category: z.string(),
state: z.enum([
"pending",
"running",
"done",
"failed",
"cancelled",
"interrupted",
]),
error: z.optional(z.string()),
warnings: z.optional(z.array(z.string())),
itemId: z.optional(z.number()),
}),
),
});
const historySchema = z.object({
ok: z.literal(true),
jobs: z.array(jobSchema).check(z.maxLength(30)),
nextCursor: z.optional(
z.nullable(
z.string().check(
z.regex(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i,
),
z.refine((value) => {
const [timestamp, id] = value.split("|");
return (
z.iso.datetime({ precision: 3 }).safeParse(timestamp).success &&
z.uuid().safeParse(id).success
);
}),
),
),
),
});
export interface FurnitureJobsPage {
jobs: ImportJob[];
nextCursor: string | null;
}
export class FurnitureJobsHttpError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
async function responseData(
response: Response,
fallback: string,
): Promise<unknown> {
const data: unknown = await response.json().catch(() => null);
if (!response.ok) {
const message =
data &&
typeof data === "object" &&
"error" in data &&
typeof data.error === "string"
? data.error
: `${fallback} (${response.status})`;
throw new FurnitureJobsHttpError(message, response.status);
}
return data;
}
export async function readFurnitureJobResponse(
response: Response,
fallback: string,
): Promise<ImportJob> {
const data = await responseData(response, fallback);
const parsed = z
.object({ ok: z.literal(true), job: jobSchema })
.safeParse(data);
if (!parsed.success) throw Error("Invalid import job response");
return parsed.data.job as ImportJob;
}
/** Each mounted history owns this client; no persisted or cross-page account cache. */
export function createFurnitureJobsClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
networkMode: "always",
staleTime: 0,
gcTime: 0,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
mutations: { retry: false },
},
});
}
export function furnitureJobsQueryOptions(
paginated: boolean,
cursor: string | null,
timeoutMs = historyTimeoutMs,
) {
return queryOptions({
queryKey: [
"furniture-import-history",
paginated,
paginated ? cursor : null,
] as const,
queryFn: async ({ signal }): Promise<FurnitureJobsPage> => {
const timeout = AbortSignal.timeout(timeoutMs);
const combined = AbortSignal.any([signal, timeout]);
const url = new URL(
endpoint,
typeof window === "undefined"
? "http://localhost"
: window.location.origin,
);
if (paginated) {
url.searchParams.set("page", "1");
if (cursor) url.searchParams.set("before", cursor);
}
try {
const response = await adminFetch(url, {
signal: combined,
cache: "no-store",
});
const data = await responseData(response, "Import history unavailable");
const parsed = historySchema.safeParse(data);
if (!parsed.success) throw Error("Invalid import history response");
return {
jobs: parsed.data.jobs as ImportJob[],
nextCursor: parsed.data.nextCursor ?? null,
};
} catch (error) {
if (timeout.aborted && !signal.aborted)
throw Error("Import history request timed out");
throw error;
}
},
});
}