feat(cms): recover drafts and failed imports with verified UI workflows
This commit is contained in:
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user