From f0b65dcbded572bfc4cbd6efdcf6c3866c04cb8f Mon Sep 17 00:00:00 2001 From: Carlos Virreira Date: Thu, 4 Jun 2026 12:49:55 +0200 Subject: [PATCH 1/9] feat(assets): vector QR label export (PDF sheet + label-printer files), paid Replace the blurry raster QR download with a single vector pipeline used everywhere: bulk + single asset, download + on-screen preview + print. Export (asset index, Actions > Export QR labels), two device-keyed journeys: - "Print on a regular printer": react-to-print PDF sheet on plain paper, with paper (Letter/A4), size (S/M/L with mm), cut guides, an on-screen print-dialog tip, and a fit-to-width preview on small screens. - "Use a label printer or sticker sheets": a zip of vector SVGs + manifest.csv + a plain-language README.txt with step-by-step instructions. Implementation: - QR rendered as inline vector (qrcode-generator), error-correction L to keep modules large and scannable on small labels; lowest viable version. - Identifier text comes from resolveDisplayCode so labels match list views. - Single-asset code preview: download, preview and print are vector too (one renderer). Barcode path unchanged (bwip-js raster). - Paid: gated by the asset-export entitlement (assertUserCanExportAssets server side; canExportAssets surfaced on the index loader for the in-dialog upsell). Single-asset QR stays free as the freemium ramp. - Branding ("Powered by shelf.nu") re-resolved against the tier server-side so the export can't be used to strip it. - Remove the old html-to-image/changedpi/.jpg raster bulk path and the 100-item cap (replaced by a generous safety bound). Tests: pure label builders incl. a sharp -> jsQR decode-roundtrip proving the printed QR encodes the right asset URL; loader wiring (resolver text, branding tier-gate, cross-org IDOR, add-on leak, no cap, paywall); the printable sheet. Adds jsqr as a test-only devDependency. --- .../assets/bulk-actions-dropdown.tsx | 2 +- .../assets/bulk-download-qr-dialog.tsx | 378 +++++++++--------- .../components/assets/qr-label-sheet.test.tsx | 74 ++++ .../app/components/assets/qr-label-sheet.tsx | 288 +++++++++++++ apps/webapp/app/components/assets/qr-svg.tsx | 69 ++++ .../components/code-preview/code-preview.tsx | 50 ++- apps/webapp/app/modules/asset/data.server.ts | 4 +- apps/webapp/app/modules/qr/label.test.ts | 166 ++++++++ apps/webapp/app/modules/qr/label.ts | 278 +++++++++++++ apps/webapp/app/modules/qr/utils.server.ts | 3 + .../assets.get-assets-for-bulk-qr-download.ts | 154 +++++-- apps/webapp/app/utils/svg-to-png.ts | 52 +++ apps/webapp/package.json | 1 + .../test/routes-tests/qr-label-export.test.ts | 296 ++++++++++++++ pnpm-lock.yaml | 8 + 15 files changed, 1578 insertions(+), 245 deletions(-) create mode 100644 apps/webapp/app/components/assets/qr-label-sheet.test.tsx create mode 100644 apps/webapp/app/components/assets/qr-label-sheet.tsx create mode 100644 apps/webapp/app/components/assets/qr-svg.tsx create mode 100644 apps/webapp/app/modules/qr/label.test.ts create mode 100644 apps/webapp/app/modules/qr/label.ts create mode 100644 apps/webapp/app/utils/svg-to-png.ts create mode 100644 apps/webapp/test/routes-tests/qr-label-export.test.ts diff --git a/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx b/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx index 47e95598ff..17c091bb27 100644 --- a/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx +++ b/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx @@ -241,7 +241,7 @@ function ConditionalDropdown() { width="full" > - Download QR Codes + Export QR labels diff --git a/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx b/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx index 7219b49c57..abfaad04df 100644 --- a/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx +++ b/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx @@ -1,21 +1,36 @@ -import { useState, useMemo, useCallback, useEffect } from "react"; -import { toBlob } from "html-to-image"; +/** + * Bulk QR Export dialog — the two-journey hub. + * + * Opened from Actions ▸ "Export QR labels" on the asset index. Fetches the + * selected assets' resolved label data, then offers two opinionated journeys: + * - **Print labels** — `` (react-to-print) plain-paper sheet. Most users. + * - **Export for my label printer** — a zip of vector `.svg` labels + a + * `manifest.csv`, built from {@link buildLabelZipEntries}. Label-printer users. + * + * Replaces the old raster path entirely: no `html-to-image`, no `changedpi`, no + * `.jpg`, no 100-item cap. Labels are vector and the codes are resolver-driven. + * + * @see {@link file://./qr-label-sheet.tsx} + * @see {@link file://./../../modules/qr/label.ts} + * @see {@link file://./../../routes/api+/assets.get-assets-for-bulk-qr-download.ts} + */ +import { useMemo, useState } from "react"; import { useAtomValue } from "jotai"; import JSZip from "jszip"; -import { DownloadIcon } from "lucide-react"; +import { DownloadIcon, FileText, Printer, Sparkles } from "lucide-react"; import { useLoaderData } from "react-router"; import { selectedBulkItemsAtom } from "~/atoms/list"; +import { QrLabelSheet } from "~/components/assets/qr-label-sheet"; +import { UpgradeMessage } from "~/components/marketing/upgrade-message"; import { useSearchParams } from "~/hooks/search-params"; import useApiQuery from "~/hooks/use-api-query"; +import { buildLabelZipEntries } from "~/modules/qr/label"; +import type { AssetIndexLoaderData } from "~/routes/_layout+/assets._index"; import type { BulkQrDownloadLoaderData } from "~/routes/api+/assets.get-assets-for-bulk-qr-download"; -import { generateHtmlFromComponent } from "~/utils/component-to-html"; import { isSelectingAllItems } from "~/utils/list"; -import { sanitizeFilename } from "~/utils/misc"; -import { QrLabel } from "../code-preview/code-preview"; import { Dialog, DialogPortal } from "../layout/dialog"; import { Button } from "../shared/button"; import { Spinner } from "../shared/spinner"; -import When from "../when/when"; type BulkDownloadQrDialogProps = { className?: string; @@ -23,246 +38,217 @@ type BulkDownloadQrDialogProps = { onClose: () => void; }; -type DownloadState = +type ZipState = | { status: "idle" } - | { status: "loading" } - | { status: "success" } + | { status: "building" } + | { status: "done" } | { status: "error"; error: string }; +/** + * @param props.isDialogOpen - controls visibility + * @param props.onClose - close handler (resets internal view) + */ export default function BulkDownloadQrDialog({ className, isDialogOpen, onClose, }: BulkDownloadQrDialogProps) { - const { totalItems } = useLoaderData<{ totalItems: number }>(); - - const [downloadState, setDownloadState] = useState({ - status: "idle", - }); - const [shouldFetchAssets, setShouldFetchAssets] = useState(false); + const [view, setView] = useState<"choose" | "pdf">("choose"); + const [zip, setZip] = useState({ status: "idle" }); const [searchParams] = useSearchParams(); + // Paid feature: gated behind the asset-export entitlement (same as CSV export). + const { canExportAssets } = useLoaderData(); + const selectedAssets = useAtomValue(selectedBulkItemsAtom); const allAssetsSelected = isSelectingAllItems(selectedAssets); - const isSelectingMoreThan100 = - selectedAssets.length > 100 || (allAssetsSelected && totalItems > 100); - - const disabled = - selectedAssets.length === 0 || downloadState.status === "loading"; - - function handleClose() { - setDownloadState({ status: "idle" }); - setShouldFetchAssets(false); - onClose(); - } - - // Prepare API query parameters + // Build the query: current filters + each selected asset id (ALL_SELECTED_KEY + // included when selecting all, so the loader re-applies the index filters). const apiSearchParams = useMemo(() => { if (selectedAssets.length === 0) return undefined; - const query = new URLSearchParams(searchParams); - selectedAssets.forEach((asset) => { - query.append("assetIds", asset.id); - }); + selectedAssets.forEach((asset) => query.append("assetIds", asset.id)); return query; }, [selectedAssets, searchParams]); - // Use useApiQuery to fetch assets data - const { data: apiResponse } = useApiQuery({ + const { data, isLoading } = useApiQuery({ api: "/api/assets/get-assets-for-bulk-qr-download", searchParams: apiSearchParams, - enabled: shouldFetchAssets && !isSelectingMoreThan100, + // Don't even fetch for free users — the loader would 403; show the upsell instead. + enabled: isDialogOpen && canExportAssets && !!apiSearchParams, }); - const processDownload = useCallback(async () => { - if (!apiResponse) return; + function handleClose() { + setView("choose"); + setZip({ status: "idle" }); + onClose(); + } + async function downloadSvgZip() { + if (!data) return; + setZip({ status: "building" }); try { - const { assets, qrIdDisplayPreference, showShelfBranding } = apiResponse; - - const zip = new JSZip(); - const qrFolder = zip.folder("qr-codes"); - - /* Converting our React component to html so that we can later convert it into an image */ - const qrNodes = assets.map((asset) => - generateHtmlFromComponent( - - ) - ); - - const toBlobOptions = { - width: 300, - height: 300, - backgroundColor: "white", - style: { - display: "flex", - alignItems: "center", - justifyContent: "center", - textAlign: "center", - }, - }; - - /** - * We are converting first qr to image separately because toBlob will cache the font - * and will not make further network requests for other qr codes. - */ - const firstQrImage = await toBlob(qrNodes[0], toBlobOptions); - - /* Converting all qr nodes into images */ - const qrImages = await Promise.all( - qrNodes.slice(1).map((qrNode) => toBlob(qrNode, toBlobOptions)) - ); - - /* Appending qr code image to zip file */ - [firstQrImage, ...qrImages].forEach((qrImage, index) => { - const asset = assets[index]; - - // Generate filename based on preference - let filename: string; - if (qrIdDisplayPreference === "SAM_ID" && asset.sequentialId) { - filename = `${asset.sequentialId}_${sanitizeFilename(asset.title)}_${ - asset.qr.id - }.jpg`; - } else { - filename = `${sanitizeFilename(asset.title)}_${asset.qr.id}.jpg`; - } - - if (!qrImage) { - return; - } - - if (qrFolder) { - qrFolder.file(filename, qrImage); - } else { - zip.file(filename, qrImage); - } - }); - - const zipBlob = await zip.generateAsync({ type: "blob" }); - const downloadLink = document.createElement("a"); - - downloadLink.href = URL.createObjectURL(zipBlob); - downloadLink.download = `qr-codes-${new Date().getTime()}.zip`; - - downloadLink.click(); - - setTimeout(() => { - URL.revokeObjectURL(downloadLink.href); - }, 4e4); - - setDownloadState({ status: "success" }); - } catch (error) { - setDownloadState({ + const archive = new JSZip(); + buildLabelZipEntries({ + assets: data.assets, + qrBaseUrl: data.qrBaseUrl, + showBranding: data.showBranding, + }).forEach((entry) => archive.file(entry.path, entry.content)); + + const blob = await archive.generateAsync({ type: "blob" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `qr-codes-${Date.now()}.zip`; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 4e4); + setZip({ status: "done" }); + } catch (cause) { + setZip({ status: "error", - error: error instanceof Error ? error.message : "Something went wrong.", + error: cause instanceof Error ? cause.message : "Something went wrong.", }); } - }, [apiResponse]); - - // Trigger download when API response is ready - useEffect(() => { - if ( - shouldFetchAssets && - apiResponse && - downloadState.status === "loading" - ) { - void processDownload(); - } - }, [shouldFetchAssets, apiResponse, downloadState.status, processDownload]); - - function handleBulkDownloadQr() { - if (isSelectingMoreThan100) { - return; - } - - // Set loading state immediately - setDownloadState({ status: "loading" }); - - // Trigger the API call if not already done - if (!apiResponse) { - setShouldFetchAssets(true); - } else { - void processDownload(); - } } + const count = allAssetsSelected + ? data?.assets.length ?? 0 + : selectedAssets.length; + return ( } > -
- {downloadState.status === "loading" ? ( -
+
+ {!canExportAssets ? ( +
+
+ +
+

Printing QR labels is a premium feature

+

+ Upgrade to make sharp, scannable QR labels for your whole + inventory at once — print a ready-to-cut sheet on a regular + printer, or download files for a label printer.{" "} + +

+
+ + +
+
+ ) : isLoading || !data ? ( +
-

Generating Zip file ...

+

Preparing {count > 0 ? count : ""} QR codes…

- ) : ( + ) : view === "pdf" ? ( <> - - Bulk downloading QR codes is only available for maximum 100 - codes at a time. Please select less codes to download. -

- } + +
+ +
+ + ) : ( + <> +

+ Make QR labels for {data.assets.length}{" "} + {data.assets.length === 1 ? "asset" : "assets"} +

+

+ Pick the option that matches your printer. Each code is already + linked to its asset — nothing to set up. +

+ +
+ + + +
- -

- Successfully downloaded qr codes. + {zip.status === "done" ? ( +

+ Downloaded. Open README.txt in the zip for + the next steps.

-
- - {downloadState.status === "error" ? ( -

{downloadState.error}

+ ) : null} + {zip.status === "error" ? ( +

{zip.error}

) : null} -
- - - - -
)} diff --git a/apps/webapp/app/components/assets/qr-label-sheet.test.tsx b/apps/webapp/app/components/assets/qr-label-sheet.test.tsx new file mode 100644 index 0000000000..f874a8bdd5 --- /dev/null +++ b/apps/webapp/app/components/assets/qr-label-sheet.test.tsx @@ -0,0 +1,74 @@ +/** + * QrLabelSheet — render + print-CSS tests (RTL / happy-dom). + * + * A20: N assets → N vector cells. A21: the print stylesheet carries `@page` and + * cells are `break-inside: avoid` (the Safari/page-split guardrail), and a size + * preset changes the grid density. A2: the QR is inline ``/``, never raster. + */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { QrLabelSheet } from "./qr-label-sheet"; + +const ASSETS = [ + { id: "a1", title: "MacBook Pro 16", qrId: "qr-1", idText: "SAM-0001" }, + { id: "a2", title: "Lock Washer", qrId: "qr-2", idText: "SAM-0002" }, + { id: "a3", title: "Sony FX6", qrId: "qr-3", idText: "SAM-0003" }, +]; + +function renderSheet(showBranding = true) { + return render( + + ); +} + +describe("QrLabelSheet", () => { + it("A20 — renders one cell per asset with its name + id text", () => { + renderSheet(); + expect(screen.getByText("MacBook Pro 16")).toBeTruthy(); + expect(screen.getByText("Lock Washer")).toBeTruthy(); + expect(screen.getByText("SAM-0003")).toBeTruthy(); + }); + + it("A2 — each QR is inline vector with modules, never an ", () => { + const { container } = renderSheet(); + const svgs = container.querySelectorAll("svg"); + expect(svgs.length).toBeGreaterThanOrEqual(3); + // The QR svgs contain rect modules; no raster anywhere. + expect(container.querySelector("svg rect")).toBeTruthy(); + expect(container.querySelector("img")).toBeNull(); + }); + + it("A21 — print stylesheet sets @page and the default paper size", () => { + const { container } = renderSheet(); + const styleText = Array.from(container.querySelectorAll("style")) + .map((s) => s.textContent) + .join(" "); + expect(styleText).toContain("@page"); + expect(styleText).toContain("size: letter"); + }); + + it("A21 — cells avoid breaking across pages", () => { + const { container } = renderSheet(); + const cell = container.querySelector('div[style*="break-inside"]'); + expect(cell?.getAttribute("style")).toContain("break-inside: avoid"); + }); + + it("A21 — choosing a size preset changes the grid density", () => { + const { container } = renderSheet(); + const sheet = () => + container.querySelector('div[style*="grid-template-columns"]'); + // default medium = 4 columns + expect(sheet()?.getAttribute("style")).toContain("repeat(4,"); + fireEvent.click(screen.getByRole("button", { name: /Small/ })); + expect(sheet()?.getAttribute("style")).toContain("repeat(6,"); + }); + + it("hides branding text when showBranding is false", () => { + renderSheet(false); + expect(screen.queryByText("Powered by shelf.nu")).toBeNull(); + }); +}); diff --git a/apps/webapp/app/components/assets/qr-label-sheet.tsx b/apps/webapp/app/components/assets/qr-label-sheet.tsx new file mode 100644 index 0000000000..003c9c83ed --- /dev/null +++ b/apps/webapp/app/components/assets/qr-label-sheet.tsx @@ -0,0 +1,288 @@ +/** + * QrLabelSheet — the "print & cut at home" journey. + * + * Renders a print-ready, paginated sheet of vector QR labels and prints it via + * `react-to-print` (browser print → print or Save-as-PDF) — the same mechanism + * every other Shelf PDF uses (`booking-overview-pdf.tsx`), so no new dependency. + * The grid is sized in real `mm`, so the printed labels are physically the chosen + * size; the QR is inline vector, so it stays sharp on any home printer. + * + * Deliberately tiny config surface (opinionation over knobs): paper × size + + * cut guides. No margins/paddings/stock templates — vector scales, the tail uses + * the SVG export journey instead. + * + * @see {@link file://./qr-svg.tsx} + * @see {@link file://./bulk-download-qr-dialog.tsx} + */ +import { useEffect, useRef, useState } from "react"; +import { useReactToPrint } from "react-to-print"; +import { QrSvg } from "~/components/assets/qr-svg"; +import { Button } from "~/components/shared/button"; +import { qrScanUrl } from "~/modules/qr/label"; +import { tw } from "~/utils/tw"; + +type SheetAsset = { id: string; title: string; qrId: string; idText: string }; + +type PaperKey = "letter" | "a4"; +type SizeKey = "small" | "medium" | "large"; + +/** Paper presets — width/height in mm + the `@page size` keyword. */ +const PAPER: Record< + PaperKey, + { wMm: number; hMm: number; page: string; label: string } +> = { + letter: { wMm: 216, hMm: 279, page: "letter", label: "Letter" }, + a4: { wMm: 210, hMm: 297, page: "A4", label: "A4" }, +}; + +/** Size presets — grid columns + QR size (mm) + type sizes (pt). */ +const SIZE: Record< + SizeKey, + { + cols: number; + qrMm: number; + gapMm: number; + nmPt: number; + idPt: number; + label: string; + } +> = { + small: { cols: 6, qrMm: 14, gapMm: 3, nmPt: 6, idPt: 5, label: "Small" }, + medium: { cols: 4, qrMm: 22, gapMm: 4, nmPt: 8, idPt: 6.5, label: "Medium" }, + large: { cols: 3, qrMm: 32, gapMm: 5, nmPt: 10, idPt: 8, label: "Large" }, +}; + +function Segmented({ + value, + options, + onChange, +}: { + value: T; + options: Array<{ value: T; label: string }>; + onChange: (v: T) => void; +}) { + return ( +
+ {options.map((o, i) => ( + + ))} +
+ ); +} + +/** + * @param props.assets - resolved label assets + * @param props.qrBaseUrl - env-derived QR base url for building scan URLs + * @param props.showBranding - effective (tier-gated) branding flag + */ +export function QrLabelSheet({ + assets, + qrBaseUrl, + showBranding, +}: { + assets: SheetAsset[]; + qrBaseUrl: string; + showBranding: boolean; +}) { + const sheetRef = useRef(null); + const [paper, setPaper] = useState("letter"); + const [size, setSize] = useState("medium"); + const [guides, setGuides] = useState(true); + + const print = useReactToPrint({ + contentRef: sheetRef, + documentTitle: "qr-labels", + }); + + const p = PAPER[paper]; + const s = SIZE[size]; + // Rough labels-per-page so the user can judge size against their need. + const perPage = + s.cols * Math.max(1, Math.floor((p.hMm - 24) / (s.qrMm + 12))); + + // Fit-to-width: scale the print-accurate sheet down on narrow screens so the + // whole page is visible (no horizontal scrolling). Print targets sheetRef + // directly — the zoom is on a wrapper — so the printed output stays real-mm. + const previewRef = useRef(null); + const [scale, setScale] = useState(1); + useEffect(() => { + const el = previewRef.current; + if (!el) return; + const sheetPx = (p.wMm * 96) / 25.4; // CSS px width of the sheet + const fit = () => setScale(Math.min(1, (el.clientWidth - 32) / sheetPx)); + fit(); + const ro = new ResizeObserver(fit); + ro.observe(el); + return () => ro.disconnect(); + }, [p.wMm]); + + return ( +
+ {/* Controls — paper × size + guides. That is the entire surface. */} +
+
+
+ Paper +
+ +
+
+
+ Label size +
+ ({ + value: k, + label: `${SIZE[k].label} (${SIZE[k].qrMm} mm)`, + }))} + /> +
+ +
+ +
+ +
+

+ {s.label} · {s.qrMm} mm QR · ~{perPage} per {p.label} page · prints on + plain paper — cut to size (not for pre-cut label sheets). +

+

+ Before you print: in the + print box, set Scale 100%{" "} + and Margins: None so labels + come out the exact size. +

+ {scale < 0.999 ? ( +

+ The preview is shrunk to fit your screen — your labels still print + at the size shown above. +

+ ) : null} +
+ + {/* Scrollable preview; on narrow screens the whole sheet scales to fit. */} +
+
+
+ + {assets.map((a) => ( +
+ {/* Box is the chosen physical size in print, but caps at the + column width on screen so the QR never overflows/clips. */} +
+ +
+
+ {a.title} +
+
+ {a.idText} +
+ {showBranding ? ( +
+ Powered by shelf.nu +
+ ) : null} +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/assets/qr-svg.tsx b/apps/webapp/app/components/assets/qr-svg.tsx new file mode 100644 index 0000000000..d77310696c --- /dev/null +++ b/apps/webapp/app/components/assets/qr-svg.tsx @@ -0,0 +1,69 @@ +/** + * QrSvg — inline, vector QR code as a React ``. + * + * Renders the QR module matrix as `` elements (no ``, no canvas, no + * data-URL), so it prints razor-sharp at any size. Shares its matrix primitive + * (`qrDarkModules`) with the zip journey's `buildLabelSvg`, so the on-screen / + * printed QR and the downloaded `.svg` are byte-equivalent encodings. + * + * @see {@link file://./../../modules/qr/label.ts} + */ +import { useMemo } from "react"; +import type { ErrorCorrectionLevel } from "qrcode-generator"; +import { DEFAULT_EC, qrDarkModules } from "~/modules/qr/label"; + +/** Quiet zone in modules — must match `buildLabelSvg` for scan parity. */ +const QUIET_ZONE = 4; + +type QrSvgProps = { + /** The URL the QR encodes (e.g. `https://eam.sh/`). */ + url: string; + /** Error-correction level. Default `L` = largest modules (best on small labels). */ + ec?: ErrorCorrectionLevel; + /** Rendered size (CSS). Defaults to filling the container width. */ + size?: string | number; + className?: string; +}; + +/** + * @param props - the URL to encode plus optional EC/size + * @returns an inline vector `` of the QR + */ +export function QrSvg({ + url, + ec = DEFAULT_EC, + size = "100%", + className, +}: QrSvgProps) { + const { count, rects } = useMemo(() => { + const { count: n, dark } = qrDarkModules(url, ec); + const out: Array<{ x: number; y: number }> = []; + for (let r = 0; r < n; r++) { + for (let c = 0; c < n; c++) { + if (dark[r][c]) out.push({ x: QUIET_ZONE + c, y: QUIET_ZONE + r }); + } + } + return { count: n, rects: out }; + }, [url, ec]); + + const dim = count + QUIET_ZONE * 2; + + return ( + + + + {rects.map((m) => ( + + ))} + + + ); +} diff --git a/apps/webapp/app/components/code-preview/code-preview.tsx b/apps/webapp/app/components/code-preview/code-preview.tsx index 8d425651a8..fad234d8bd 100644 --- a/apps/webapp/app/components/code-preview/code-preview.tsx +++ b/apps/webapp/app/components/code-preview/code-preview.tsx @@ -4,13 +4,16 @@ import type { BarcodeType } from "@prisma/client"; import { changeDpiDataUrl } from "changedpi"; import { toPng } from "html-to-image"; import { useReactToPrint } from "react-to-print"; +import { QrSvg } from "~/components/assets/qr-svg"; import { BarcodeDisplay } from "~/components/barcode/barcode-display"; import { Button } from "~/components/shared/button"; import { useCurrentOrganization } from "~/hooks/use-current-organization"; import { useUserRoleHelper } from "~/hooks/user-user-role-helper"; +import { buildLabelSvg } from "~/modules/qr/label"; import { resolveShowShelfBranding } from "~/utils/branding"; import { useBarcodePermissions } from "~/utils/permissions/use-barcode-permissions"; import { slugify } from "~/utils/slugify"; +import { svgToPngBlob } from "~/utils/svg-to-png"; import { tw } from "~/utils/tw"; import { waitForImagesToLoad } from "~/utils/wait-for-images"; import { AddBarcodeDialog } from "./add-barcode-dialog"; @@ -29,6 +32,8 @@ export interface CodeType { qrData?: { size: SizeKeys; src: string; + /** The scan URL the QR encodes — used to re-render as vector for download. */ + url: string; }; // Barcode specific barcodeData?: { @@ -99,6 +104,7 @@ interface CodePreviewProps { size: SizeKeys; id: string; src: string; + url: string; }; }; barcodes?: Array<{ @@ -151,6 +157,7 @@ export const CodePreview = ({ qrData: { size: qrObj.qr.size, src: qrObj.qr.src, + url: qrObj.qr.url, }, }); } @@ -246,6 +253,35 @@ export const CodePreview = ({ }, [item, selectedCode]); function downloadCode(e: MouseEvent) { + // Vector path for the Shelf QR: render the label as SVG and rasterize at + // high resolution — genuinely sharp, unlike the legacy bitmap capture. One + // renderer with the bulk export. (Barcodes fall through to the DOM capture.) + if (selectedCode?.type === "qr" && selectedCode.qrData) { + e.preventDefault(); + const idText = + organization?.qrIdDisplayPreference === "SAM_ID" && sequentialId + ? sequentialId + : selectedCode.id; + void svgToPngBlob( + buildLabelSvg({ + url: selectedCode.qrData.url, + title: item.name, + idText, + showBranding: resolvedShowShelfBranding, + }) + ) + .then((blob) => { + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = fileName; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 4e4); + }) + // eslint-disable-next-line no-console + .catch(console.error); + return; + } + const captureDiv = captureDivRef.current; const downloadBtn = downloadBtnRef.current; @@ -426,6 +462,8 @@ export type QrDef = { id?: string; size?: SizeKeys; src?: string; + /** Scan URL — when present the QR renders as inline vector (sharp preview + print). */ + url?: string; }; interface QrLabelProps { @@ -449,10 +487,14 @@ export const QrLabel = React.forwardRef(
{title}
- {`${data?.qr?.size}-shelf-qr-code.png`} + {data?.qr?.url ? ( + + ) : ( + {`${data?.qr?.size}-shelf-qr-code.png`} + )}
diff --git a/apps/webapp/app/modules/asset/data.server.ts b/apps/webapp/app/modules/asset/data.server.ts index f90cd8cf26..b0a08fde64 100644 --- a/apps/webapp/app/modules/asset/data.server.ts +++ b/apps/webapp/app/modules/asset/data.server.ts @@ -26,7 +26,7 @@ import { PermissionEntity, } from "~/utils/permissions/permission.data"; import { hasPermission } from "~/utils/permissions/permission.validator.server"; -import { canImportAssets } from "~/utils/subscription.server"; +import { canExportAssets, canImportAssets } from "~/utils/subscription.server"; import { resolveUserDisplayName } from "~/utils/user"; import { parseFiltersWithHierarchy } from "./query.server"; import { @@ -287,6 +287,7 @@ export async function simpleModeLoader({ modelName, hasActiveFilters, canImportAssets: canImportAssets(tierLimit) && canImport, + canExportAssets: canExportAssets(tierLimit), searchFieldLabel: "Search assets", searchFieldTooltip: { title: "Search your asset database", @@ -559,6 +560,7 @@ export async function advancedModeLoader({ modelName, hasActiveFilters, canImportAssets: canImportAssets(tierLimit) && advCanImport, + canExportAssets: canExportAssets(tierLimit), searchFieldLabel: "Search assets", searchFieldTooltip: { title: "Search your asset database", diff --git a/apps/webapp/app/modules/qr/label.test.ts b/apps/webapp/app/modules/qr/label.test.ts new file mode 100644 index 0000000000..a7a852dd26 --- /dev/null +++ b/apps/webapp/app/modules/qr/label.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment node +/** + * QR Label — pure unit tests. + * + * The load-bearing test is A1′: rasterize the generated label SVG with `sharp` + * and decode it with `jsQR`, asserting it reads back the exact asset URL. That + * verifies the FEATURE (a scannable code that encodes the right asset), not the + * library — replacing a tautological "module count == lib output" assertion. + */ +import jsQR from "jsqr"; +import { describe, expect, it } from "vitest"; +import { + buildLabelSvg, + buildLabelZipEntries, + buildManifestCsv, + MANIFEST_HEADERS, + qrModuleCount, + qrScanUrl, + type LabelAsset, +} from "./label"; + +/** Rasterize an SVG string and decode any QR within it back to a string. */ +async function decodeQrFromSvg(svg: string): Promise { + const sharp = (await import("sharp")).default; + const { data, info } = await sharp(Buffer.from(svg)) + .resize({ width: 700 }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const result = jsQR(new Uint8ClampedArray(data), info.width, info.height); + return result?.data ?? null; +} + +const asset = (over: Partial = {}): LabelAsset => ({ + id: "asset-1", + title: "MacBook Pro 16", + qrId: "kQ7m2aX", + idText: "SAM-0001", + ...over, +}); + +describe("buildLabelSvg", () => { + it("A1′ — the printed QR decodes back to the exact asset URL (EC L)", async () => { + const url = "https://eam.sh/kQ7m2aX"; + const svg = buildLabelSvg({ + url, + title: "MacBook Pro 16", + idText: "SAM-0001", + showBranding: true, + }); + await expect(decodeQrFromSvg(svg)).resolves.toBe(url); + }); + + it("A1′ — still decodes at higher error-correction (EC Q)", async () => { + const url = "https://eam.sh/p3Rn9bY"; + const svg = buildLabelSvg({ + url, + title: "Lock Washer", + idText: "SAM-0002", + showBranding: false, + ec: "Q", + }); + await expect(decodeQrFromSvg(svg)).resolves.toBe(url); + }); + + it("A2 — output is vector /, never raster", () => { + const svg = buildLabelSvg({ + url: "https://eam.sh/x", + title: "T", + idText: "i", + showBranding: true, + }); + expect(svg).toContain(" { + const svg = buildLabelSvg({ + url: "https://eam.sh/x", + title: 'A & B "', + idText: "i", + showBranding: false, + }); + expect(svg).toContain("A & B <quote>""); + expect(svg).not.toContain(""); + }); + + it("omits the branding text when showBranding is false", () => { + const off = buildLabelSvg({ + url: "u", + title: "t", + idText: "i", + showBranding: false, + }); + const on = buildLabelSvg({ + url: "u", + title: "t", + idText: "i", + showBranding: true, + }); + expect(off).not.toContain("shelf.nu"); + expect(on).toContain("Powered by shelf.nu"); + }); +}); + +describe("module minimization (A3)", () => { + it("higher error-correction costs more modules — the reason L is the default", () => { + const url = "https://eam.sh/kQ7m2aX"; + const l = qrModuleCount(url, "L"); + const m = qrModuleCount(url, "M"); + const q = qrModuleCount(url, "Q"); + expect(l).toBeLessThanOrEqual(m); + expect(m).toBeLessThanOrEqual(q); + }); + + it("a short (shortener) URL stays at a low version — big, scannable modules", () => { + // version 1..4 => 21..33 modules; assert we don't over-version a short URL. + expect(qrModuleCount("https://eam.sh/kQ7m2aX", "L")).toBeLessThanOrEqual( + 33 + ); + }); +}); + +describe("buildManifestCsv (A12–A14)", () => { + const base = "https://eam.sh"; + + it("A12 — header + one row per asset", () => { + const csv = buildManifestCsv( + [asset({ id: "a1" }), asset({ id: "a2" })], + base + ); + const lines = csv.split("\r\n"); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe(MANIFEST_HEADERS.map((h) => `"${h}"`).join(",")); + }); + + it("A13 — the manifest URL is the SAME string the QR encodes", () => { + const a = asset({ qrId: "kQ7m2aX" }); + const csv = buildManifestCsv([a], base); + expect(csv).toContain(`"${qrScanUrl(base, a.qrId)}"`); + }); + + it("A14 — a name with comma and quote is RFC-4180 escaped", () => { + const csv = buildManifestCsv([asset({ title: 'Cam, "A"' })], base); + expect(csv).toContain('"Cam, ""A"""'); + }); +}); + +describe("buildLabelZipEntries (A22)", () => { + it("one .svg per asset under qr-codes/, plus a root manifest.csv — never .jpg", () => { + const entries = buildLabelZipEntries({ + assets: [asset({ id: "a1" }), asset({ id: "a2", title: "Lock Washer" })], + qrBaseUrl: "https://eam.sh", + showBranding: true, + }); + const paths = entries.map((e) => e.path); + expect(paths).toContain("manifest.csv"); + expect(paths).toContain("README.txt"); + const svgs = paths.filter((p) => p.endsWith(".svg")); + expect(svgs).toHaveLength(2); + expect(svgs.every((p) => p.startsWith("qr-codes/"))).toBe(true); + expect(paths.some((p) => p.endsWith(".jpg"))).toBe(false); + }); +}); diff --git a/apps/webapp/app/modules/qr/label.ts b/apps/webapp/app/modules/qr/label.ts new file mode 100644 index 0000000000..3dd7392fb2 --- /dev/null +++ b/apps/webapp/app/modules/qr/label.ts @@ -0,0 +1,278 @@ +/** + * QR Label — pure, client-safe label generation + * + * Single source of truth for turning an asset's resolved code data into a + * print-ready **vector** label. Used by both customer-facing export journeys: + * - the PDF label sheet (`` renders the QR via {@link qrDarkModules}) + * - the SVG-files zip ({@link buildLabelZipEntries} → standalone `.svg` + `manifest.csv`) + * + * Design notes: + * - **Vector only.** No raster, no `html-to-image`, no `changedpi`. The QR is + * drawn as `` modules from `qrcode-generator`'s matrix. + * - **Minimize module count.** Version is auto-selected (`qrcode(0, ...)` picks + * the lowest that fits) and EC defaults to `L` — the largest modules, which is + * what scans on a small label at low printer DPI. Higher EC ⇒ more modules ⇒ + * smaller modules ⇒ worse at small physical sizes; treat EC as an empirical + * print-tested choice, not a durability default. + * - Pure + client-safe: no `.server` imports, no DB, no side effects. Safe to + * call from a loader or a browser component, and to unit-test directly. + * + * @see {@link file://./../../components/assets/qr-label-sheet.tsx} (PDF journey) + * @see {@link file://./../../components/assets/bulk-download-qr-dialog.tsx} (zip journey) + * @see {@link file://./../../routes/api+/assets.get-assets-for-bulk-qr-download.ts} (loader) + */ +import QRCode, { type ErrorCorrectionLevel } from "qrcode-generator"; +import { sanitizeFilename } from "~/utils/sanitize-filename"; + +/** Default error-correction: `L` = largest modules = best on small/low-DPI labels. */ +export const DEFAULT_EC: ErrorCorrectionLevel = "L"; + +/** Standard QR quiet zone, in modules, required for reliable scanning. */ +const QUIET_ZONE = 4; + +/** The per-asset data a label needs (already org-scoped + resolved upstream). */ +export type LabelAsset = { + /** The asset id (manifest only). */ + id: string; + /** Human-readable asset name shown on the label and used for the filename. */ + title: string; + /** The Shelf QR id — the scannable graphic always encodes this. */ + qrId: string; + /** + * The identifier text printed under the QR. Comes from `resolveDisplayCode` + * upstream (SAM id / QR id / barcode value) so the label matches list views. + */ + idText: string; +}; + +/** + * Builds the full scan URL a Shelf QR encodes. + * @param qrBaseUrl - env-derived base (`getQrBaseUrl()`), e.g. `https://eam.sh` + * @param qrId - the asset's QR id + * @returns the URL string, identical to what the printed QR encodes + */ +export const qrScanUrl = (qrBaseUrl: string, qrId: string): string => + `${qrBaseUrl}/${qrId}`; + +/** + * Computes the QR module matrix for a URL — the shared primitive behind every + * QR we draw (PDF cell and zip svg) so the two render paths can never diverge. + * + * @param url - the string to encode + * @param ec - error-correction level (default {@link DEFAULT_EC}) + * @returns `count` (modules per side) and `dark[r][c]` module states + */ +export function qrDarkModules( + url: string, + ec: ErrorCorrectionLevel = DEFAULT_EC +): { count: number; dark: boolean[][] } { + // type 0 => auto-pick the LOWEST version that fits => fewest, biggest modules. + const code = QRCode(0, ec); + code.addData(url); + code.make(); + const count = code.getModuleCount(); + const dark: boolean[][] = []; + for (let r = 0; r < count; r++) { + const row: boolean[] = []; + for (let c = 0; c < count; c++) { + row.push(code.isDark(r, c)); + } + dark.push(row); + } + return { count, dark }; +} + +/** Module count only — used by tests/UI to reason about module density. */ +export const qrModuleCount = ( + url: string, + ec: ErrorCorrectionLevel = DEFAULT_EC +): number => qrDarkModules(url, ec).count; + +/** XML-escape text destined for an SVG `` node. */ +const escapeXml = (s: string): string => + s.replace(/[<>&"']/g, (ch) => + ch === "<" + ? "<" + : ch === ">" + ? ">" + : ch === "&" + ? "&" + : ch === '"' + ? """ + : "'" + ); + +/** + * Builds a standalone, self-contained **vector** label SVG: the QR (with quiet + * zone) + asset name + identifier text + optional "Powered by shelf.nu". Scales + * to any physical size via its `viewBox`, so label software can place it at the + * user's exact label dimensions with no quality loss. + * + * @returns a complete `` string (one file in the zip journey) + */ +export function buildLabelSvg({ + url, + title, + idText, + showBranding, + ec = DEFAULT_EC, +}: { + url: string; + title: string; + idText: string; + showBranding: boolean; + ec?: ErrorCorrectionLevel; +}): string { + const { count, dark } = qrDarkModules(url, ec); + const qrSize = count + QUIET_ZONE * 2; // module units, incl. quiet zone + + // Text block laid out in the same module-unit coordinate space, below the QR. + const titleSize = Math.max(2, qrSize * 0.085); + const idSize = titleSize * 0.85; + const gap = qrSize * 0.06; + const titleY = qrSize + gap + titleSize; + const idY = titleY + idSize * 1.3; + const brandSize = idSize * 0.8; + const brandY = idY + brandSize * 1.5; + const totalH = (showBranding ? brandY : idY) + gap; + const cx = qrSize / 2; + + let rects = ""; + for (let r = 0; r < count; r++) { + for (let c = 0; c < count; c++) { + if (dark[r][c]) { + rects += ``; + } + } + } + + const brand = showBranding + ? `Powered by shelf.nu` + : ""; + + return ( + `` + + `` + + `${rects}` + + `${escapeXml( + title + )}` + + `${escapeXml( + idText + )}` + + brand + + `` + ); +} + +/** RFC-4180 escape: wrap in quotes, double any embedded quotes. */ +const csvCell = (value: string): string => `"${value.replace(/"/g, '""')}"`; + +/** Manifest column headers — stable contract for the merge workflow. */ +export const MANIFEST_HEADERS = ["Asset ID", "Name", "QR ID", "Scan URL"]; + +/** + * Builds the `manifest.csv` content pairing each asset with its code + scan URL. + * The URL is the SAME string the label QR encodes (see {@link qrScanUrl}) so the + * printed code and the merge data can never diverge. + * + * @param assets - the resolved label assets + * @param qrBaseUrl - env-derived QR base url + * @returns CSV text (CRLF line endings, RFC-4180 quoted) + */ +export function buildManifestCsv( + assets: LabelAsset[], + qrBaseUrl: string +): string { + const rows = assets.map((a) => + [a.id, a.title, a.qrId, qrScanUrl(qrBaseUrl, a.qrId)].map(csvCell).join(",") + ); + return [MANIFEST_HEADERS.map(csvCell).join(","), ...rows].join("\r\n"); +} + +/** Deterministic, filesystem-safe filename for an asset's label (default `.svg`). */ +export const labelFileName = ( + asset: LabelAsset, + ext: "svg" | "png" = "svg" +): string => `${sanitizeFilename(asset.title)}_${asset.qrId}.${ext}`; + +/** One file destined for the export zip. */ +export type ZipEntry = { path: string; content: string }; + +/** + * Plain-language README dropped into the export zip so the SVG/CSV files stop + * being a wall for non-technical users — the #1 source of "what do I do with + * these files?" support tickets. Kept jargon-light on purpose. + */ +export const ZIP_README = `HOW TO USE THESE FILES +====================== + +This zip has one QR image (.svg) for each of your assets, inside the +"qr-codes" folder, plus a spreadsheet called "manifest.csv". + +Each QR code is already linked to the right asset in Shelf. + +---------------------------------------------------------------------- +JUST WANT TO PRINT ONE? + Open any .svg file in the "qr-codes" folder and print it. + (SVG stays perfectly sharp at any size.) + +WANT TO PRINT MANY ON A LABEL PRINTER (Brother, Dymo, Avery...)? + 1. Open your label software (e.g. Brother P-touch Editor, Dymo + Connect, or Avery Design & Print). + 2. Import "manifest.csv" as a data source / mail merge. + 3. Put the "Name" column on the label as text, and the "Scan URL" + column as a QR code. + 4. Print ONE label first and scan it with your phone to check it + works, then print the rest. +---------------------------------------------------------------------- + +The "manifest.csv" columns: + - Asset ID : the asset's id in Shelf + - Name : the asset name (put this on the label) + - QR ID : the code's id + - Scan URL : what the QR points to (use this to make the QR code) + +Stuck? Reply to your Shelf support email and we'll help. +`; + +/** + * Assembles the complete set of zip entries for the SVG export journey: one + * vector `.svg` per asset under `qr-codes/`, plus a root `manifest.csv`. Pure so + * the file map is unit-testable without JSZip/Blob; the dialog just feeds these + * to JSZip. + * + * @returns array of `{ path, content }` — every svg path ends `.svg`, never `.jpg` + */ +export function buildLabelZipEntries({ + assets, + qrBaseUrl, + showBranding, + ec = DEFAULT_EC, +}: { + assets: LabelAsset[]; + qrBaseUrl: string; + showBranding: boolean; + ec?: ErrorCorrectionLevel; +}): ZipEntry[] { + const entries: ZipEntry[] = assets.map((a) => ({ + path: `qr-codes/${labelFileName(a)}`, + content: buildLabelSvg({ + url: qrScanUrl(qrBaseUrl, a.qrId), + title: a.title, + idText: a.idText, + showBranding, + ec, + }), + })); + entries.push({ + path: "manifest.csv", + content: buildManifestCsv(assets, qrBaseUrl), + }); + entries.push({ path: "README.txt", content: ZIP_README }); + return entries; +} diff --git a/apps/webapp/app/modules/qr/utils.server.ts b/apps/webapp/app/modules/qr/utils.server.ts index e0584573fc..99cd19c2bc 100644 --- a/apps/webapp/app/modules/qr/utils.server.ts +++ b/apps/webapp/app/modules/qr/utils.server.ts @@ -49,6 +49,9 @@ export async function generateCode({ size: size, src, id: qr.id, + // The exact scan URL the QR encodes — lets the client re-render the code + // as vector (sharp downloads) without round-tripping the bitmap. + url: `${baseUrl}/${qr.id}`, }, }; } catch (cause) { diff --git a/apps/webapp/app/routes/api+/assets.get-assets-for-bulk-qr-download.ts b/apps/webapp/app/routes/api+/assets.get-assets-for-bulk-qr-download.ts index f47208709d..5504a08ac6 100644 --- a/apps/webapp/app/routes/api+/assets.get-assets-for-bulk-qr-download.ts +++ b/apps/webapp/app/routes/api+/assets.get-assets-for-bulk-qr-download.ts @@ -1,52 +1,96 @@ +/** + * Bulk QR Label Export — loader + * + * Returns the data the asset-index QR export needs to build vector labels + * client-side (PDF sheet + SVG/manifest zip). Replaces the old raster path: + * no per-asset `sharp` QR encoding here — we return each asset's existing QR id + * (every asset has one per the `createAsset` contract) and let the browser draw + * the vector QR from it. + * + * Honors three existing Shelf concepts (the guardrails): + * - **Resolver:** the printed identifier text comes from `resolveDisplayCode`, + * the same source of truth as list views and ``. + * - **Branding is tier-gated revenue:** "Powered by shelf.nu" is re-resolved + * server-side against the tier — a free workspace can't strip it via export. + * - **Org-scoped:** all assets are filtered by `organizationId` (no cross-org IDOR). + * + * @see {@link file://./../../modules/qr/label.ts} + * @see {@link file://./../../components/assets/bulk-download-qr-dialog.tsx} + */ import type { Prisma } from "@prisma/client"; -import { data, type ActionFunctionArgs } from "react-router"; +import { data, type LoaderFunctionArgs } from "react-router"; import { db } from "~/database/db.server"; import { getAssetsWhereInput } from "~/modules/asset/utils.server"; -import { generateQrObj } from "~/modules/qr/utils.server"; +import { + ASSET_CODE_RESOLUTION_SELECT, + resolveDisplayCode, +} from "~/modules/barcode/display"; +import { getQrBaseUrl } from "~/modules/qr/utils.server"; +import { getOrganizationTierLimit } from "~/modules/tier/service.server"; import { makeShelfError, ShelfError } from "~/utils/error"; import { payload, error } from "~/utils/http.server"; import { ALL_SELECTED_KEY } from "~/utils/list"; +import { Logger } from "~/utils/logger"; import { PermissionAction, PermissionEntity, } from "~/utils/permissions/permission.data"; import { requirePermission } from "~/utils/roles.server"; +import { + assertUserCanExportAssets, + canHideShelfBranding, +} from "~/utils/subscription.server"; + +/** + * Safety bound on a single export. The old 100-item cap existed to protect the + * browser from rasterizing that many DOM nodes and the server from that many + * `sharp` encodes — both deleted. This bound only guards against an accidental + * multi-thousand-asset export OOMing the browser zip; it is generous, not the + * old constraint. + */ +export const MAX_BULK_QR_EXPORT = 1500; export type BulkQrDownloadLoaderData = { assets: Array<{ + /** Asset id — manifest only. */ id: string; + /** Asset name — shown on the label, used for the filename. */ title: string; - sequentialId: string | null; - createdAt: Date; - qr: { - id: string; - src: string; - size: "small" | "cable" | "medium" | "large"; - }; + /** The Shelf QR id the scannable graphic encodes. */ + qrId: string; + /** Resolver-driven identifier text printed under the QR. */ + idText: string; }>; - qrIdDisplayPreference: string; - showShelfBranding: boolean; + /** Env-derived QR base url; the client builds `${qrBaseUrl}/${qrId}`. */ + qrBaseUrl: string; + /** Effective branding flag AFTER the tier gate (never a raw org toggle). */ + showBranding: boolean; }; /** - * This API find all/some assets in current organization and returns the required data - * for generating qr codes after validation. + * Finds the selected (or all-filtered) assets in the current organization and + * returns the data needed to generate QR labels, after permission + tier checks. */ -export async function loader({ context, request }: ActionFunctionArgs) { +export async function loader({ context, request }: LoaderFunctionArgs) { const authSession = context.getSession(); const { userId } = authSession; try { - const { organizationId, currentOrganization } = await requirePermission({ - userId, - request, - entity: PermissionEntity.qr, - action: PermissionAction.read, - }); + const { organizationId, organizations, currentOrganization } = + await requirePermission({ + userId, + request, + entity: PermissionEntity.qr, + action: PermissionAction.read, + }); - const url = new URL(request.url); - const searchParams = url.searchParams; + // Paid feature: print-ready QR label export is gated behind the same + // entitlement as the CSV asset export. Enforced server-side so a free user + // can't reach the data even by calling the API directly (the UI also shows + // an upgrade prompt instead of the export — see bulk-download-qr-dialog). + await assertUserCanExportAssets({ organizationId, organizations }); + const searchParams = new URL(request.url).searchParams; const assetIds = searchParams.getAll("assetIds"); if (assetIds.length === 0) { @@ -59,7 +103,7 @@ export async function loader({ context, request }: ActionFunctionArgs) { }); } - /* If we are selecting all assets in list then we have to consider other filters */ + /* Select-all carries the magic key + current filters; otherwise explicit ids. */ const where: Prisma.AssetWhereInput = assetIds.includes(ALL_SELECTED_KEY) ? getAssetsWhereInput({ organizationId, @@ -67,40 +111,64 @@ export async function loader({ context, request }: ActionFunctionArgs) { }) : { id: { in: assetIds }, organizationId }; - const assets = await db.asset.findMany({ + const rows = await db.asset.findMany({ where, - select: { id: true, title: true, createdAt: true, sequentialId: true }, + select: { id: true, title: true, ...ASSET_CODE_RESOLUTION_SELECT }, }); - if (assets.length > 100) { + if (rows.length > MAX_BULK_QR_EXPORT) { throw new ShelfError({ cause: null, label: "Assets", - message: - "Bulk downloading QR codes is only available for maximum 100 codes at a time. Please select less codes to download.", + shouldBeCaptured: false, + message: `QR export is limited to ${MAX_BULK_QR_EXPORT} assets at a time. Please narrow your selection.`, }); } - const assetsWithQrObj = []; + /* Branding is revenue: re-resolve against the tier, never trust the org toggle alone. */ + const tierLimit = await getOrganizationTierLimit({ + organizationId, + organizations, + }); + const showBranding = canHideShelfBranding(tierLimit) + ? currentOrganization.showShelfBranding + : true; - for (const asset of assets) { - const qrObj = await generateQrObj({ - assetId: asset.id, - organizationId, - userId, - }); + const resolverOrg = { + qrIdDisplayPreference: currentOrganization.qrIdDisplayPreference, + barcodesEnabled: currentOrganization.barcodesEnabled ?? false, + }; - assetsWithQrObj.push({ - ...asset, - qr: qrObj.qr, - }); - } + const assets = rows + .map((asset) => { + const qrId = asset.qrCodes[0]?.id; + if (!qrId) { + // Data-drift anomaly: every asset should have a QR (createAsset). Skip + // gracefully rather than ship a label that can't scan, and surface it. + Logger.warn({ + message: `Asset ${asset.id} has no QR code; excluded from QR export.`, + additionalData: { assetId: asset.id, organizationId }, + }); + return null; + } + const resolved = resolveDisplayCode({ + entity: asset, + organization: resolverOrg, + }); + return { + id: asset.id, + title: asset.title, + qrId, + idText: resolved.value || qrId, + }; + }) + .filter((a): a is NonNullable => a !== null); return data( payload({ - assets: assetsWithQrObj, - qrIdDisplayPreference: currentOrganization.qrIdDisplayPreference, - showShelfBranding: currentOrganization.showShelfBranding, + assets, + qrBaseUrl: getQrBaseUrl(), + showBranding, }) ); } catch (cause) { diff --git a/apps/webapp/app/utils/svg-to-png.ts b/apps/webapp/app/utils/svg-to-png.ts new file mode 100644 index 0000000000..7b29d490a0 --- /dev/null +++ b/apps/webapp/app/utils/svg-to-png.ts @@ -0,0 +1,52 @@ +/** + * svgToPngBlob — rasterize an SVG string to a high-resolution PNG (browser-only). + * + * Used to give users a PNG that is genuinely sharp because it's rasterized from + * the **vector** label at a high pixel width — not an upscaled small bitmap. + * + * @see {@link file://./../modules/qr/label.ts} (buildLabelSvg) + */ + +/** Default raster width in px — ~27px/module for a typical label = crisp at print. */ +const DEFAULT_PNG_WIDTH = 1024; + +/** + * Rasterizes an SVG string to a PNG blob, preserving the SVG's aspect ratio. + * + * @param svg - a complete `` string (must carry a `viewBox`) + * @param pxWidth - target width in pixels (height derived from the viewBox) + * @returns a PNG `Blob` + * @throws if the canvas context is unavailable or encoding fails + */ +export async function svgToPngBlob( + svg: string, + pxWidth: number = DEFAULT_PNG_WIDTH +): Promise { + const vb = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/); + const w = vb ? parseFloat(vb[1]) : 1; + const h = vb ? parseFloat(vb[2]) : 1; + const pxHeight = Math.max(1, Math.round(pxWidth * (h / w))); + + const img = new Image(); + img.width = pxWidth; + img.height = pxHeight; + img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + await img.decode(); + + const canvas = document.createElement("canvas"); + canvas.width = pxWidth; + canvas.height = pxHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas 2D context unavailable for PNG export"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, pxWidth, pxHeight); + ctx.drawImage(img, 0, 0, pxWidth, pxHeight); + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => + blob ? resolve(blob) : reject(new Error("PNG encoding failed")), + "image/png" + ); + }); +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index bc0492fe9f..dc82c69220 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -174,6 +174,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-tailwindcss": "^3.18.2", "happy-dom": "20.8.9", + "jsqr": "^1.4.0", "msw": "^2.13.2", "nodemailer-mock": "^2.0.10", "npm-run-all": "^4.1.5", diff --git a/apps/webapp/test/routes-tests/qr-label-export.test.ts b/apps/webapp/test/routes-tests/qr-label-export.test.ts new file mode 100644 index 0000000000..f2f0620538 --- /dev/null +++ b/apps/webapp/test/routes-tests/qr-label-export.test.ts @@ -0,0 +1,296 @@ +// @vitest-environment node +/** + * Bulk QR Export loader — behavioral wiring tests. + * + * The db mock honors org-scoping + id filtering, so IDOR and the lifted cap are + * verified by OUTPUT (foreign asset absent / 150 returned), not by inspecting + * the where-clause. Resolver text and the branding tier-gate run the real pure + * functions; only db/auth/tier/env are mocked. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ALL_SELECTED = "all-selected"; + +/** Mutable per-test config the mocks read. */ +const CONFIG: { + org: { + qrIdDisplayPreference: string; + barcodesEnabled: boolean; + showShelfBranding: boolean; + }; + canHide: boolean; +} = { + org: { + qrIdDisplayPreference: "QR_ID", + barcodesEnabled: false, + showShelfBranding: true, + }, + canHide: true, +}; + +/** In-memory asset store the db mock filters. */ +let STORE: any[] = []; + +const dataMock = vi.hoisted(() => ({ + fn: (value: unknown, init?: ResponseInit) => + new Response(JSON.stringify(value), { + status: init?.status ?? 200, + headers: { "Content-Type": "application/json" }, + }), +})); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { ...actual, data: dataMock.fn }; +}); + +vi.mock("~/database/db.server", () => ({ + db: { + asset: { + findMany: vi.fn(async ({ where }: any) => + STORE.filter((a) => { + if (where.organizationId && a.organizationId !== where.organizationId) + return false; + if (where.id?.in && !where.id.in.includes(a.id)) return false; + return true; + }) + ), + }, + }, +})); + +vi.mock("~/utils/roles.server", () => ({ requirePermission: vi.fn() })); +vi.mock("~/modules/tier/service.server", () => ({ + getOrganizationTierLimit: vi.fn(), +})); +vi.mock("~/modules/qr/utils.server", () => ({ + getQrBaseUrl: () => "https://eam.sh", +})); +vi.mock("~/modules/asset/utils.server", () => ({ + // select-all path: just scope to the org (the real filter logic isn't under test here). + getAssetsWhereInput: ({ organizationId }: any) => ({ organizationId }), +})); +vi.mock("~/utils/subscription.server", () => ({ + canHideShelfBranding: vi.fn(), + assertUserCanExportAssets: vi.fn(), +})); +vi.mock("~/utils/logger", () => ({ + Logger: { warn: vi.fn(), error: vi.fn() }, +})); + +const { loader } = await import( + "~/routes/api+/assets.get-assets-for-bulk-qr-download" +); +const { requirePermission } = await import("~/utils/roles.server"); +const { getOrganizationTierLimit } = await import( + "~/modules/tier/service.server" +); +const { canHideShelfBranding, assertUserCanExportAssets } = await import( + "~/utils/subscription.server" +); + +function makeAsset(over: Partial = {}) { + return { + id: "a1", + title: "MacBook Pro 16", + organizationId: "org-1", + sequentialId: "SAM-0001", + preferredBarcodeId: null, + qrCodes: [{ id: "qr-a1" }], + barcodes: [], + ...over, + }; +} + +async function callLoader(assetIds: string[]) { + const params = assetIds + .map((id) => `assetIds=${encodeURIComponent(id)}`) + .join("&"); + const args: any = { + context: { getSession: () => ({ userId: "user-1" }) }, + request: new Request( + `https://x/api/assets/get-assets-for-bulk-qr-download?${params}` + ), + params: {}, + }; + const res = (await loader(args)) as unknown as Response; + return { status: res.status, body: await res.json() }; +} + +beforeEach(() => { + vi.clearAllMocks(); + STORE = [makeAsset()]; + CONFIG.org = { + qrIdDisplayPreference: "QR_ID", + barcodesEnabled: false, + showShelfBranding: true, + }; + CONFIG.canHide = true; + vi.mocked(requirePermission).mockImplementation( + async () => + ({ + organizationId: "org-1", + organizations: [ + { + id: "org-1", + type: "TEAM", + name: "Org", + imageId: null, + userId: "user-1", + }, + ], + currentOrganization: CONFIG.org, + }) as any + ); + vi.mocked(getOrganizationTierLimit).mockResolvedValue({ + canHideShelfBranding: true, + } as any); + vi.mocked(canHideShelfBranding).mockImplementation(() => CONFIG.canHide); + vi.mocked(assertUserCanExportAssets).mockResolvedValue(undefined); +}); + +describe("paid-feature gate", () => { + it("blocks free users — assertUserCanExportAssets throws → non-200, no assets", async () => { + vi.mocked(assertUserCanExportAssets).mockRejectedValue( + Object.assign(new Error("Upgrade required"), { status: 403 }) + ); + const { status, body } = await callLoader(["a1"]); + expect(status).not.toBe(200); + expect(body.assets).toBeUndefined(); + }); +}); + +describe("resolver-driven idText (A4–A8)", () => { + it("A5 — QR_ID preference prints the QR id", async () => { + CONFIG.org.qrIdDisplayPreference = "QR_ID"; + const { body } = await callLoader(["a1"]); + expect(body.assets[0].idText).toBe("qr-a1"); + }); + + it("A4 — SAM_ID preference prints the sequentialId", async () => { + CONFIG.org.qrIdDisplayPreference = "SAM_ID"; + STORE = [makeAsset({ sequentialId: "SAM-0007" })]; + const { body } = await callLoader(["a1"]); + expect(body.assets[0].idText).toBe("SAM-0007"); + }); + + it("A6 — barcode preference prints the barcode value when the add-on is on", async () => { + CONFIG.org = { + qrIdDisplayPreference: "Code128", + barcodesEnabled: true, + showShelfBranding: true, + }; + STORE = [ + makeAsset({ + barcodes: [{ id: "b1", type: "Code128", value: "WH-ABC-001" }], + }), + ]; + const { body } = await callLoader(["a1"]); + expect(body.assets[0].idText).toBe("WH-ABC-001"); + }); + + it("A7 (security) — barcode value does NOT leak when the add-on is off", async () => { + CONFIG.org = { + qrIdDisplayPreference: "Code128", + barcodesEnabled: false, + showShelfBranding: true, + }; + STORE = [ + makeAsset({ + barcodes: [{ id: "b1", type: "Code128", value: "WH-ABC-001" }], + }), + ]; + const { body } = await callLoader(["a1"]); + expect(body.assets[0].idText).toBe("qr-a1"); + expect(JSON.stringify(body)).not.toContain("WH-ABC-001"); + }); + + it("A8 — per-asset preferredBarcode overrides the workspace preference", async () => { + CONFIG.org = { + qrIdDisplayPreference: "QR_ID", + barcodesEnabled: true, + showShelfBranding: true, + }; + STORE = [ + makeAsset({ + preferredBarcodeId: "b2", + barcodes: [{ id: "b2", type: "Code39", value: "PREF-9" }], + }), + ]; + const { body } = await callLoader(["a1"]); + expect(body.assets[0].idText).toBe("PREF-9"); + }); +}); + +describe("branding tier-gate (A9–A11, security)", () => { + it("A9 — branding shown when the org wants it", async () => { + CONFIG.canHide = true; + CONFIG.org.showShelfBranding = true; + const { body } = await callLoader(["a1"]); + expect(body.showBranding).toBe(true); + }); + + it("A10 — branding hidden when allowed and the org opts out", async () => { + CONFIG.canHide = true; + CONFIG.org.showShelfBranding = false; + const { body } = await callLoader(["a1"]); + expect(body.showBranding).toBe(false); + }); + + it("A11 (bypass) — a free tier CANNOT strip branding via export", async () => { + CONFIG.canHide = false; + CONFIG.org.showShelfBranding = false; + const { body } = await callLoader(["a1"]); + expect(body.showBranding).toBe(true); + }); +}); + +describe("loader wiring (A15–A19, A24)", () => { + it("A15 — 150 assets export with no cap error", async () => { + STORE = Array.from({ length: 150 }, (_, i) => + makeAsset({ id: `a${i}`, qrCodes: [{ id: `qr-${i}` }] }) + ); + const { status, body } = await callLoader([ALL_SELECTED]); + expect(status).toBe(200); + expect(body.assets).toHaveLength(150); + }); + + it("A16 (IDOR) — a foreign-org asset is absent from the output", async () => { + STORE = [ + makeAsset({ id: "a1" }), + makeAsset({ id: "foreign", organizationId: "org-2" }), + ]; + const { body } = await callLoader(["a1", "foreign"]); + const ids = body.assets.map((a: any) => a.id); + expect(ids).toEqual(["a1"]); + expect(ids).not.toContain("foreign"); + }); + + it("A18 — returns the env-derived qrBaseUrl", async () => { + const { body } = await callLoader(["a1"]); + expect(body.qrBaseUrl).toBe("https://eam.sh"); + }); + + it("A19 — no raster src field on returned assets", async () => { + const { body } = await callLoader(["a1"]); + expect(body.assets[0]).not.toHaveProperty("src"); + expect(body.assets[0]).toEqual({ + id: "a1", + title: "MacBook Pro 16", + qrId: "qr-a1", + idText: "qr-a1", + }); + }); + + it("A24 — an asset with no QR is skipped gracefully, not crashed", async () => { + STORE = [makeAsset({ id: "a1" }), makeAsset({ id: "a2", qrCodes: [] })]; + const { status, body } = await callLoader(["a1", "a2"]); + expect(status).toBe(200); + expect(body.assets.map((a: any) => a.id)).toEqual(["a1"]); + }); + + it("returns 400 when no asset ids are provided", async () => { + const { status } = await callLoader([]); + expect(status).toBe(400); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a93407f713..58c5f5e958 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -600,6 +600,9 @@ importers: happy-dom: specifier: 20.8.9 version: 20.8.9 + jsqr: + specifier: ^1.4.0 + version: 1.4.0 msw: specifier: ^2.13.2 version: 2.14.2(@types/node@25.3.0)(typescript@6.0.3) @@ -7527,6 +7530,9 @@ packages: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -18189,6 +18195,8 @@ snapshots: ms: 2.1.3 semver: 7.7.4 + jsqr@1.4.0: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 From 72e4420411d8e9e5b41f151798a28ef2c961f83b Mon Sep 17 00:00:00 2001 From: Carlos Virreira Date: Thu, 4 Jun 2026 13:16:10 +0200 Subject: [PATCH 2/9] fix(assets): address review (CSV injection, query bound, error payload) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neutralize spreadsheet formula injection in manifest.csv — asset names starting with =, +, -, @ are apostrophe-prefixed before quoting. - Bound the export query with take = cap + 1, so a huge select-all isn't fully loaded (with relations) just to be rejected by the limit. - Guard the export dialog against the loader's error payload (e.g. select-all over the limit): show the message instead of crashing on data.assets. Adds tests for the CSV neutralization and the query bound. --- .../assets/bulk-download-qr-dialog.tsx | 17 +++++++++++++++++ apps/webapp/app/modules/qr/label.test.ts | 8 ++++++++ apps/webapp/app/modules/qr/label.ts | 12 ++++++++++-- .../assets.get-assets-for-bulk-qr-download.ts | 3 +++ .../test/routes-tests/qr-label-export.test.ts | 10 +++++++++- 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx b/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx index abfaad04df..4134ae9643 100644 --- a/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx +++ b/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx @@ -115,6 +115,16 @@ export default function BulkDownloadQrDialog({ ? data?.assets.length ?? 0 : selectedAssets.length; + // The loader can return an error payload (e.g. a select-all over the export + // limit). useApiQuery surfaces it as `data` without an `assets` array, so + // guard before reading `data.assets` instead of crashing. + const hasAssets = Array.isArray((data as { assets?: unknown })?.assets); + const apiErrorMessage = + data && !hasAssets + ? (data as { error?: { message?: string } }).error?.message ?? + "Something went wrong preparing the labels." + : null; + return (

Preparing {count > 0 ? count : ""} QR codes…

+ ) : apiErrorMessage ? ( +
+

{apiErrorMessage}

+ +
) : view === "pdf" ? ( <>