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

@@ -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();
});
+37 -5
View File
@@ -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",