diff --git a/src/components/export-panel.test.tsx b/src/components/export-panel.test.tsx new file mode 100644 index 0000000..d8ce285 --- /dev/null +++ b/src/components/export-panel.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { type Project } from "#/domain/types.ts" +import { completeForm, cycleSettings, layoutFixture, submissionFixture } from "#/test/fixtures.ts" + +const exportProject = vi.fn() + +vi.mock("#/lib/api.ts", async () => { + const actual = await vi.importActual("#/lib/api.ts") + return { + ...actual, + projectApi: { + ...actual.projectApi, + export: (...args: unknown[]) => exportProject(...args), + }, + } +}) + +const { ExportPanel } = await import("./export-panel.tsx") + +beforeEach(() => { + exportProject.mockReset() + exportProject.mockRejectedValue(new Error("Stop after request")) +}) + +afterEach(cleanup) + +function projectWithBlockingProblem(): Project { + const layout = layoutFixture() + const submission = submissionFixture("10000000-0000-4000-8000-000000000001", 1) + const pageId = `submission:${submission.id}` + + return { + id: layout.projectId, + title: "Test book", + occasion: null, + state: "closed", + formSchema: completeForm, + layouts: [layout], + submissions: [submission], + bookStatus: "current", + archivedAt: null, + book: { + projectId: layout.projectId, + settings: cycleSettings, + pages: [ + { + id: pageId, + kind: "submission", + submissionId: submission.id, + layoutId: layout.id, + problems: [ + { + id: `${pageId}:text:outside-print-area`, + code: "outside-print-area", + pageId, + elementId: "text", + message: "Text is outside the safe area.", + blocking: true, + }, + ], + }, + ], + sourceFingerprint: "current-source", + generatedAt: "2026-08-25T00:00:00.000Z", + updatedAt: "2026-08-25T00:00:00.000Z", + }, + } as Project +} + +describe("print export blocking problem override", () => { + it("requires an explicit opt-in and confirmation before requesting the export", async () => { + render() + + const exportButton = screen.getByRole("button", { name: "Export PDF + report" }) + expect((exportButton as HTMLButtonElement).disabled).toBe(true) + + fireEvent.click(screen.getByRole("switch", { name: "Export despite blocking problems" })) + expect(screen.getByText("1 blocking page problem accepted")).toBeTruthy() + expect( + (screen.getByRole("button", { name: "Export PDF + report" }) as HTMLButtonElement).disabled + ).toBe(false) + + fireEvent.click(screen.getByRole("button", { name: "Export PDF + report" })) + expect(exportProject).not.toHaveBeenCalled() + + fireEvent.click(await screen.findByRole("button", { name: "Export anyway" })) + + await waitFor(() => + expect(exportProject).toHaveBeenCalledExactlyOnceWith( + "99999999-9999-4999-8999-999999999999", + { + marks: false, + allowBlockingProblems: true, + reviewedBookFingerprint: "current-source", + } + ) + ) + }) +}) diff --git a/src/components/export-panel.tsx b/src/components/export-panel.tsx index f2167cc..786da97 100644 --- a/src/components/export-panel.tsx +++ b/src/components/export-panel.tsx @@ -9,10 +9,21 @@ import { PrinterIcon, XCircleIcon, } from "lucide-react" -import { useState } from "react" +import { useEffect, useState } from "react" import { toast } from "sonner" import { Alert, AlertDescription, AlertTitle } from "#/components/ui/alert.tsx" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "#/components/ui/alert-dialog.tsx" import { Badge } from "#/components/ui/badge.tsx" import { Button, buttonVariants } from "#/components/ui/button.tsx" import { @@ -23,15 +34,17 @@ import { CardHeader, CardTitle, } from "#/components/ui/card.tsx" -import { Field, FieldDescription, FieldLabel } from "#/components/ui/field.tsx" +import { Field, FieldDescription, FieldGroup, FieldLabel } from "#/components/ui/field.tsx" import { Switch } from "#/components/ui/switch.tsx" import { type ExportArtifact, type Project } from "#/domain/types.ts" import { pageSpecification } from "#/domain/page-format.ts" +import { captureAnalyticsEvent } from "#/lib/analytics.ts" import { projectApi } from "#/lib/api.ts" export function ExportPanel({ project }: { project: Project }) { const specification = pageSpecification(project.pageFormat, project.pageOrientation) const [marks, setMarks] = useState(false) + const [allowBlockingProblems, setAllowBlockingProblems] = useState(false) const [exporting, setExporting] = useState(false) const [artifact, setArtifact] = useState(null) const blocking = @@ -40,15 +53,28 @@ export function ExportPanel({ project }: { project: Project }) { const ready = project.bookStatus === "current" && Boolean(project.book) && - blocking === 0 && + (blocking === 0 || allowBlockingProblems) && !project.archivedAt + useEffect(() => { + setAllowBlockingProblems(false) + }, [project.id, project.book?.sourceFingerprint]) + const exportBook = async () => { setExporting(true) setArtifact(null) try { - const result = await projectApi.export(project.id, marks) + const result = await projectApi.export(project.id, { + marks, + allowBlockingProblems, + reviewedBookFingerprint: project.book?.sourceFingerprint ?? null, + }) setArtifact(result) + captureAnalyticsEvent("export:completed", { + blocking_override: allowBlockingProblems && blocking > 0, + problem_count: blocking, + printer_marks: marks, + }) toast.success("PDF and preflight report exported") } catch (error) { toast.error(error instanceof Error ? error.message : "Export failed") @@ -110,6 +136,16 @@ export function ExportPanel({ project }: { project: Project }) { Return to Book review and regenerate the complete book. + ) : blocking > 0 && allowBlockingProblems ? ( + + + + {blocking} blocking page problem{blocking === 1 ? "" : "s"} accepted + + + Export is enabled. The preflight report will list every accepted problem. + + ) : blocking > 0 ? ( @@ -118,7 +154,7 @@ export function ExportPanel({ project }: { project: Project }) { {blocking === 1 ? "" : "s"} - Resolve or explicitly override every blocking problem, then regenerate. + Enable export despite blocking problems below to accept them for this export. ) : ( @@ -139,29 +175,83 @@ export function ExportPanel({ project }: { project: Project }) { - - setMarks(checked === true)} - /> -
- Crop and printer marks - - Off by default. Pages are never imposed as spreads. - -
-
+ + + setMarks(checked === true)} + /> +
+ Crop and printer marks + + Off by default. Pages are never imposed as spreads. + +
+
+ {blocking > 0 && project.bookStatus === "current" && ( + + { + const enabled = checked === true + setAllowBlockingProblems(enabled) + captureAnalyticsEvent("export:blocking_override_changed", { + enabled, + problem_count: blocking, + }) + }} + /> +
+ + Export despite blocking problems + + + Accept {blocking} blocking page problem{blocking === 1 ? "" : "s"} for this + export. The preflight report will list each one. + +
+
+ )} +
- + {blocking > 0 && allowBlockingProblems ? ( + + }> + {exporting ? ( + + ) : ( + + )} + {exporting ? "Rendering and preflighting…" : "Export PDF + report"} + + + + Export with blocking problems? + + The PDF may contain content outside the print area, clipped text, or other + visible problems. The preflight report will record all {blocking} accepted + problem{blocking === 1 ? "" : "s"}. + + + + Cancel + Export anyway + + + + ) : ( + + )} diff --git a/src/domain/preflight.test.ts b/src/domain/preflight.test.ts index 90ae68b..b802437 100644 --- a/src/domain/preflight.test.ts +++ b/src/domain/preflight.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { createPreflightReport, hasFailedPreflight } from "./preflight.ts" +import { createPreflightReport, hasFailedPreflight, reportAsText } from "./preflight.ts" import { generateBook } from "./generation.ts" import { pageSpecification } from "./page-format.ts" import { completeForm, cycleSettings, layoutFixture, submissionFixture } from "../test/fixtures.ts" @@ -133,4 +133,43 @@ describe("preflight", () => { ) expect(hasFailedPreflight(report)).toBe(false) }) + + it("records explicitly accepted blocking problems without bypassing stale generation", () => { + const generated = book() + const acceptedProblem = { + id: "accepted-outside-print-area", + code: "outside-print-area" as const, + pageId: generated.pages[0]!.id, + elementId: "text-element", + message: "Text is outside the 6 mm safe area.", + blocking: true, + } + generated.pages[0]!.problems.push(acceptedProblem) + const input = { + projectId: generated.projectId, + book: generated, + bookStatus: "current" as const, + pageCount: generated.pages.length, + fontsEmbedded: true, + outputIntentEmbedded: true, + pageBoxesValid: true, + assetResolutionMetadata: true, + assetResolutionCount: 0, + marks: false, + allowBlockingProblems: true, + } + + const report = createPreflightReport(input) + + expect(report.checks).toContainEqual( + expect.objectContaining({ id: "blocking-problems", status: "warning" }) + ) + expect(report.ignoredProblems).toEqual([acceptedProblem]) + expect(reportAsText(report)).toContain( + `${acceptedProblem.pageId} / text-element: ${acceptedProblem.message}` + ) + expect(hasFailedPreflight(report)).toBe(false) + + expect(hasFailedPreflight(createPreflightReport({ ...input, bookStatus: "stale" }))).toBe(true) + }) }) diff --git a/src/domain/preflight.ts b/src/domain/preflight.ts index 765f1b0..6438a13 100644 --- a/src/domain/preflight.ts +++ b/src/domain/preflight.ts @@ -13,11 +13,13 @@ export function createPreflightReport(input: { assetResolutionMetadata: boolean assetResolutionCount: number marks: boolean + allowBlockingProblems?: boolean pageSpecification?: PageSpecification now?: string }): ExportReport { const specification = input.pageSpecification ?? pageSpecification() const problems = blockingProblems(input.book) + const blockingProblemsAccepted = input.allowBlockingProblems === true && problems.length > 0 const emptyDecorativeImages = input.book.pages.flatMap((page) => page.problems.filter((problem) => problem.code === "empty-decorative-image") ) @@ -34,11 +36,13 @@ export function createPreflightReport(input: { { id: "blocking-problems", label: "No blocking layout problems", - status: problems.length === 0 ? "pass" : "fail", + status: problems.length === 0 ? "pass" : blockingProblemsAccepted ? "warning" : "fail", detail: problems.length === 0 ? "No unresolved blocking problems were found." - : `${problems.length} blocking problem(s) remain.`, + : blockingProblemsAccepted + ? `The organizer accepted ${problems.length} blocking problem(s) for this export.` + : `${problems.length} blocking problem(s) remain.`, }, { id: "page-boxes", @@ -74,7 +78,9 @@ export function createPreflightReport(input: { status: !input.assetResolutionMetadata ? "fail" : problems.some((problem) => problem.code === "image-blocking-resolution") - ? "fail" + ? blockingProblemsAccepted + ? "warning" + : "fail" : input.book.pages.some((page) => page.problems.some((problem) => problem.code === "image-low-resolution") ) @@ -118,6 +124,7 @@ export function createPreflightReport(input: { }, checks, overrides, + ignoredProblems: blockingProblemsAccepted ? problems : [], pdfx: { target: "PDF/X-4", structurallyVerified: @@ -158,6 +165,14 @@ export function reportAsText(report: ExportReport): string { ? report.overrides.map((override) => `- ${override.assetId}: ${override.reason}`) : ["- None"]), "", + "Accepted blocking problems", + ...(report.ignoredProblems?.length + ? report.ignoredProblems.map( + (problem) => + `- ${problem.pageId}${problem.elementId ? ` / ${problem.elementId}` : ""}: ${problem.message}` + ) + : ["- None"]), + "", "PDF/X-4", `- Structural checks: ${report.pdfx.structurallyVerified ? "passed" : "failed"}`, `- Limitation: ${report.pdfx.limitation ?? "None"}`, diff --git a/src/domain/types.ts b/src/domain/types.ts index 430b2c6..91428f8 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -317,6 +317,7 @@ export interface ExportReport { } checks: PreflightCheck[] overrides: Array<{ assetId: string; reason: string }> + ignoredProblems?: PageProblem[] pdfx: { target: "PDF/X-4" structurallyVerified: boolean diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 724131a..10b405f 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -48,6 +48,15 @@ interface AnalyticsEvents { blocking: boolean focuses_element: boolean } + "export:blocking_override_changed": { + enabled: boolean + problem_count: number + } + "export:completed": { + blocking_override: boolean + problem_count: number + printer_marks: boolean + } } export function captureAnalyticsEvent( diff --git a/src/lib/api.ts b/src/lib/api.ts index 17a2f94..238ff82 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -91,9 +91,16 @@ export const projectApi = { method: "PATCH", body: JSON.stringify({ focalPoint }), }), - export: (projectId: string, marks: boolean) => + export: ( + projectId: string, + options: { + marks: boolean + allowBlockingProblems: boolean + reviewedBookFingerprint: string | null + } + ) => api(`/api/projects/${projectId}/export`, { method: "POST", - body: JSON.stringify({ marks }), + body: JSON.stringify(options), }), } diff --git a/src/routes/api.projects.$projectId.export.ts b/src/routes/api.projects.$projectId.export.ts index 13f036f..f283dc6 100644 --- a/src/routes/api.projects.$projectId.export.ts +++ b/src/routes/api.projects.$projectId.export.ts @@ -4,7 +4,11 @@ import { z } from "zod" import { exportProject } from "#/server/export-service.ts" import { jsonError, readJson } from "#/server/http.ts" -const exportSchema = z.object({ marks: z.boolean().default(false) }) +const exportSchema = z.object({ + marks: z.boolean().default(false), + allowBlockingProblems: z.boolean().default(false), + reviewedBookFingerprint: z.string().nullable().default(null), +}) export const Route = createFileRoute("/api/projects/$projectId/export")({ server: { @@ -12,7 +16,7 @@ export const Route = createFileRoute("/api/projects/$projectId/export")({ POST: async ({ params, request }) => { try { const input = exportSchema.parse(await readJson(request)) - return Response.json(await exportProject(params.projectId, input.marks), { status: 201 }) + return Response.json(await exportProject(params.projectId, input), { status: 201 }) } catch (error) { return jsonError(error) } diff --git a/src/server/export-service.test.ts b/src/server/export-service.test.ts new file mode 100644 index 0000000..8f63c80 --- /dev/null +++ b/src/server/export-service.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest" + +import { type Project } from "#/domain/types.ts" +import { completeForm, cycleSettings, layoutFixture, submissionFixture } from "#/test/fixtures.ts" + +const getProject = vi.fn() + +vi.mock("./repository.ts", () => ({ + getProject: (...args: unknown[]) => getProject(...args), + recordExport: vi.fn(), +})) + +const { exportProject } = await import("./export-service.ts") + +function currentProject(): Project { + const layout = layoutFixture() + const submission = submissionFixture("10000000-0000-4000-8000-000000000001", 1) + + return { + id: layout.projectId, + title: "Test book", + occasion: null, + state: "closed", + formSchema: completeForm, + layouts: [layout], + submissions: [submission], + bookStatus: "current", + archivedAt: null, + book: { + projectId: layout.projectId, + settings: cycleSettings, + pages: [ + { + id: `submission:${submission.id}`, + kind: "submission", + submissionId: submission.id, + layoutId: layout.id, + problems: [ + { + id: "new-book-problem", + code: "outside-print-area", + pageId: `submission:${submission.id}`, + elementId: "text", + message: "Text is outside the safe area.", + blocking: true, + }, + ], + }, + ], + sourceFingerprint: "new-book", + generatedAt: "2026-08-25T00:00:00.000Z", + updatedAt: "2026-08-25T00:00:00.000Z", + }, + } as Project +} + +describe("export blocking problem override", () => { + it("rejects an override accepted for a different generated book", async () => { + getProject.mockResolvedValue(currentProject()) + + await expect( + exportProject("99999999-9999-4999-8999-999999999999", { + marks: false, + allowBlockingProblems: true, + reviewedBookFingerprint: "reviewed-book", + }) + ).rejects.toMatchObject({ + status: 409, + message: + "The book changed after you accepted its problems. Review it again before exporting.", + }) + }) +}) diff --git a/src/server/export-service.ts b/src/server/export-service.ts index 2cd3668..f2c7065 100644 --- a/src/server/export-service.ts +++ b/src/server/export-service.ts @@ -7,7 +7,14 @@ import { putObject } from "./object-store" import { inspectPdf, renderBookPdf } from "./pdf-renderer" import { getProject, recordExport } from "./repository" -export async function exportProject(projectId: string, marks: boolean): Promise { +export async function exportProject( + projectId: string, + options: { + marks: boolean + allowBlockingProblems: boolean + reviewedBookFingerprint: string | null + } +): Promise { const project = await getProject(projectId, true) if (project.archivedAt) { throw new HttpError(409, "This project is archived. Unarchive it before making changes.") @@ -21,8 +28,17 @@ export async function exportProject(projectId: string, marks: boolean): Promise< "This preview is stale. Regenerate the complete book before exporting." ) } + if ( + options.allowBlockingProblems && + options.reviewedBookFingerprint !== project.book.sourceFingerprint + ) { + throw new HttpError( + 409, + "The book changed after you accepted its problems. Review it again before exporting." + ) + } const problems = blockingProblems(project.book) - if (problems.length > 0) { + if (problems.length > 0 && !options.allowBlockingProblems) { throw new HttpError( 409, `Resolve ${problems.length} blocking page problem(s) before exporting.`, @@ -36,7 +52,7 @@ export async function exportProject(projectId: string, marks: boolean): Promise< layouts: project.layouts, submissions: project.submissions ?? [], form: project.formSchema, - marks, + marks: options.marks, pageFormat: project.pageFormat, pageOrientation: project.pageOrientation, }) @@ -51,7 +67,8 @@ export async function exportProject(projectId: string, marks: boolean): Promise< pageBoxesValid: inspection.pageBoxesValid, assetResolutionMetadata: inspection.assetResolutionMetadata, assetResolutionCount: inspection.assetResolutionCount, - marks, + marks: options.marks, + allowBlockingProblems: options.allowBlockingProblems, pageSpecification: specification, }) if (hasFailedPreflight(report)) { diff --git a/visual-artifacts/screenshots/export-despite-problems-after.png b/visual-artifacts/screenshots/export-despite-problems-after.png new file mode 100644 index 0000000..5993311 Binary files /dev/null and b/visual-artifacts/screenshots/export-despite-problems-after.png differ diff --git a/visual-artifacts/screenshots/export-despite-problems-before.png b/visual-artifacts/screenshots/export-despite-problems-before.png new file mode 100644 index 0000000..8698bba Binary files /dev/null and b/visual-artifacts/screenshots/export-despite-problems-before.png differ