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
@@ -1,4 +1,4 @@
|
||||
import { apiError, apiOk } from "@/lib/api";
|
||||
import { apiError, apiJson, apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
@@ -28,9 +28,16 @@ export const POST = withAdmin(
|
||||
);
|
||||
return apiOk({ attachmentId });
|
||||
} catch (error) {
|
||||
return apiError(
|
||||
error instanceof Error ? error.message : "Invalid .nitro file",
|
||||
400,
|
||||
return apiJson(
|
||||
{
|
||||
error: "Invalid original .nitro file",
|
||||
code:
|
||||
error instanceof Error &&
|
||||
error.message.startsWith("This bundle does not belong")
|
||||
? "mismatchedFile"
|
||||
: "invalidFile",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
attachment: vi.fn(),
|
||||
guard: vi.fn(),
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
@@ -40,6 +41,10 @@ vi.mock("@/lib/services/furni-job-store", async (original) => ({
|
||||
}));
|
||||
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
|
||||
|
||||
vi.mock("@/lib/services/furni-attachment", () => ({
|
||||
readFurnitureAttachment: mocks.attachment,
|
||||
}));
|
||||
|
||||
import { GET, PATCH, POST } from "./route";
|
||||
|
||||
const item = {
|
||||
@@ -179,3 +184,53 @@ it("rejects malformed history cursors", async () => {
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("validates recovery attachment ownership and classname before queueing", async () => {
|
||||
const id = randomUUID(),
|
||||
attachmentId = randomUUID();
|
||||
mocks.attachment.mockResolvedValue(Buffer.from("valid"));
|
||||
mocks.retry.mockResolvedValue({ id });
|
||||
expect(
|
||||
(
|
||||
await PATCH(
|
||||
request({ id, action: "retry", attachments: { chair: attachmentId } }),
|
||||
ctx,
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(mocks.attachment).toHaveBeenCalledWith(attachmentId, "chair", 7);
|
||||
expect(mocks.retry).toHaveBeenCalledWith(id, 7, { chair: attachmentId });
|
||||
});
|
||||
it("rejects foreign or mismatched recovery files without retrying", async () => {
|
||||
mocks.retry.mockClear();
|
||||
mocks.attachment.mockRejectedValue(Error("owner mismatch"));
|
||||
expect(
|
||||
(
|
||||
await PATCH(
|
||||
request({
|
||||
id: randomUUID(),
|
||||
action: "retry",
|
||||
attachments: { chair: randomUUID() },
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
expect(mocks.retry).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects invalid recovery attachment paths", async () => {
|
||||
mocks.retry.mockClear();
|
||||
expect(
|
||||
(
|
||||
await PATCH(
|
||||
request({
|
||||
id: randomUUID(),
|
||||
action: "retry",
|
||||
attachments: { "../chair": randomUUID() },
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
expect(mocks.retry).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { after } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiError, apiOk } from "@/lib/api";
|
||||
import { apiError, apiJson, apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { getRequestId } from "@/lib/foundation/request-context";
|
||||
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { getSource } from "@/lib/services/clone-sources";
|
||||
import { readFurnitureAttachment } from "@/lib/services/furni-attachment";
|
||||
import { ImportJobStore, validJobId } from "@/lib/services/furni-job-store";
|
||||
import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
|
||||
|
||||
@@ -115,10 +116,41 @@ export const PATCH = withAdmin(
|
||||
"Background import queue is temporarily unavailable",
|
||||
503,
|
||||
);
|
||||
const job = await new ImportJobStore().retry(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
);
|
||||
const parsed = z
|
||||
.record(z.string(), z.uuid())
|
||||
.refine(
|
||||
(value) =>
|
||||
Object.keys(value).length <= 500 &&
|
||||
(!Object.keys(value).length ||
|
||||
validateClassnames(Object.keys(value))),
|
||||
)
|
||||
.safeParse(body.attachments ?? {});
|
||||
if (!parsed.success) return apiError("Invalid recovery attachments", 400);
|
||||
const attachments = parsed.data;
|
||||
let job: Awaited<ReturnType<ImportJobStore["retry"]>>;
|
||||
try {
|
||||
for (const [classname, attachmentId] of Object.entries(attachments))
|
||||
await readFurnitureAttachment(
|
||||
attachmentId,
|
||||
classname,
|
||||
ctx.session.user.id,
|
||||
);
|
||||
const store = new ImportJobStore();
|
||||
job = Object.keys(attachments).length
|
||||
? await store.retry(body.id, ctx.session.user.id, attachments)
|
||||
: await store.retry(body.id, ctx.session.user.id);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const code = message.startsWith("Recovery already started")
|
||||
? "alreadyStarted"
|
||||
: message.includes("not eligible")
|
||||
? "notEligible"
|
||||
: "invalidFile";
|
||||
return apiJson(
|
||||
{ error: "Cannot resume with these attachments", code },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!job)
|
||||
return apiError(
|
||||
"No safe remaining items to retry, or import job not found",
|
||||
|
||||
Reference in new issue
Block a user