diff --git a/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx b/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx index f4aaa41f1a..174a2efb5e 100644 --- a/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx +++ b/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx @@ -245,7 +245,7 @@ function ConditionalDropdown() { width="full" > - Download QR Codes + Export QR labels diff --git a/apps/webapp/app/components/assets/bulk-download-qr-dialog.test.tsx b/apps/webapp/app/components/assets/bulk-download-qr-dialog.test.tsx deleted file mode 100644 index bd0bff2128..0000000000 --- a/apps/webapp/app/components/assets/bulk-download-qr-dialog.test.tsx +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Regression tests for {@link BulkDownloadQrDialog}. - * - * The dialog is permanently mounted on the asset index (only the `isDialogOpen` - * prop toggles), so its fetch state survives the user closing it, changing the - * active filter/selection, and reopening it. Two distinct bugs are guarded here: - * - * 1. Stale-cache reuse: a second "Download QR codes" after a filter change must - * fetch fresh data for the now-current filters, not reuse the first response. - * 2. Superseded slow response: dismissing the loading dialog mid-fetch and - * starting a new download must NOT let the first (slow) response complete and - * zip the previous filter's assets — the newest request always wins. - * - * Observable, implementation-agnostic signals: each download issues a fetch - * whose URL reflects the then-current params, and only the latest request's - * assets are ever rasterized into the zip. - * - * @see {@link file://./bulk-download-qr-dialog.tsx} - */ - -import { act, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Provider, createStore } from "jotai"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { selectedBulkItemsAtom } from "~/atoms/list"; -import type { ListItemData } from "~/components/list/list-item"; -import type { BulkQrDownloadLoaderData } from "~/routes/api+/assets.get-assets-for-bulk-qr-download"; -import BulkDownloadQrDialog from "./bulk-download-qr-dialog"; - -/** - * Hoisted, mutable state read by the (hoisted) `vi.mock` factories: - * - `searchParams`: what the mocked `useSearchParams` returns (the active filter) - * - `renderedTitles`: titles of every asset actually rasterized into a zip, so a - * test can assert which request's assets were processed. - */ -const hoisted = vi.hoisted(() => ({ - searchParams: new URLSearchParams(), - renderedTitles: [] as string[], -})); - -// why: the dialog imports `useSearchParams` from this cookie/org-context-aware -// wrapper; in a unit test we only need it to surface the current filter params -// so the dialog folds them into the request URL. -vi.mock("~/hooks/search-params", () => ({ - useSearchParams: () => [hoisted.searchParams, vi.fn()] as const, -})); - -// why: replace only `useLoaderData` so the dialog reads a small `totalItems` -// (keeping the "more than 100" branch off) without running a real route loader. -vi.mock("react-router", async (importActual) => { - const actual = await importActual>(); - return { ...actual, useLoaderData: () => ({ totalItems: 5 }) }; -}); - -// why: happy-dom cannot rasterize a DOM node to an image, so html-to-image's -// toBlob would throw. Resolve a tiny blob so the download path completes. -vi.mock("html-to-image", () => ({ - toBlob: vi.fn(() => Promise.resolve(new Blob(["x"], { type: "image/jpeg" }))), -})); - -// why: avoids rendering via renderToStaticMarkup (pulls in QR-codec -// deps irrelevant to this test). It also records the title of each asset that -// reaches rasterization, which is how the race test proves WHICH request's -// assets were zipped. -vi.mock("~/utils/component-to-html", () => ({ - generateHtmlFromComponent: (element: { props?: { title?: string } }) => { - if (element?.props?.title) hoisted.renderedTitles.push(element.props.title); - return document.createElement("div"); - }, -})); - -// why: QrLabel is only ever passed to the (mocked) generateHtmlFromComponent, -// never rendered here, and its module chain (AddBarcodeDialog -> scan-barcode-tab -// -> scanner -> lottie-web) touches canvas at import time and crashes under -// happy-dom. A stub cuts that chain. -vi.mock("~/components/code-preview/code-preview", () => ({ - QrLabel: (props: { title?: string }) => props as unknown as null, -})); - -// why: zip generation is irrelevant to the assertions and its Blob plumbing is -// unreliable under happy-dom; a no-op archive lets processDownload reach its -// success state deterministically. -vi.mock("jszip", () => { - class FakeZip { - folder() { - return { file: () => undefined }; - } - file() { - return undefined; - } - generateAsync() { - return Promise.resolve(new Blob(["zip"])); - } - } - return { default: FakeZip }; -}); - -/** Records every URL the dialog requests. */ -const fetchSpy = vi.fn(); - -/** - * Controls how the mocked `fetch` resolves: - * - "auto": resolve immediately with a payload matching the URL's assetIds. - * - "manual": defer; the test resolves `pending[i]` by hand to control ordering. - */ -const fetchControl = { - mode: "auto" as "auto" | "manual", - pending: [] as Array<{ - url: string; - resolve: (data: BulkQrDownloadLoaderData) => void; - }>, -}; - -/** Builds a valid loader payload whose assets match the requested ids. */ -function payloadFor(assetIds: string[]): BulkQrDownloadLoaderData { - return { - assets: assetIds.map((id) => ({ - id, - title: `Asset ${id}`, - sequentialId: null, - createdAt: new Date("2026-01-01T00:00:00.000Z"), - qr: { - id: `qr-${id}`, - src: "data:image/png;base64,xxx", - size: "medium" as const, - }, - })), - qrIdDisplayPreference: "QR_ID", - showShelfBranding: true, - }; -} - -beforeEach(() => { - fetchControl.mode = "auto"; - fetchControl.pending = []; - hoisted.searchParams = new URLSearchParams(); - hoisted.renderedTitles = []; - - // why: install the fetch spy AFTER MSW's interception (mirrors - // use-api-query.test.ts) so the dialog's request is captured here and never - // reaches MSW (which errors on unhandled requests). - vi.spyOn(globalThis, "fetch").mockImplementation((( - input: RequestInfo | URL - ) => { - const url = String(input); - fetchSpy(url); - const ids = new URL(url, "http://localhost").searchParams.getAll( - "assetIds" - ); - if (fetchControl.mode === "manual") { - return new Promise((resolve) => { - fetchControl.pending.push({ - url, - resolve: (data) => - resolve({ json: () => Promise.resolve(data) } as Response), - }); - }); - } - return Promise.resolve({ - json: () => Promise.resolve(payloadFor(ids)), - } as Response); - }) as typeof fetch); - fetchSpy.mockClear(); - - // why: happy-dom does not implement object URLs; processDownload calls both. - globalThis.URL.createObjectURL = vi.fn(() => "blob:mock"); - globalThis.URL.revokeObjectURL = vi.fn(); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -/** - * Renders the dialog under a dedicated jotai store (so selection state persists - * across rerenders) with the dialog kept open for the whole test — both bugs - * only reproduce while component state survives close/reopen. - */ -function renderDialog() { - const store = createStore(); - const onClose = vi.fn(); - const utils = render( - - - - ); - return { store, onClose, ...utils }; -} - -/** Sets the active filter params + selected assets, flushing React effects. */ -async function setFilterAndSelection( - store: ReturnType, - filterQuery: string, - assetIds: string[] -) { - hoisted.searchParams = new URLSearchParams(filterQuery); - await act(async () => { - store.set( - selectedBulkItemsAtom, - assetIds.map((id) => ({ id }) as unknown as ListItemData) - ); - // Flush microtask-scheduled effects (useMemo recompute) so the dialog - // observes the new filter + selection before we interact with it. - await Promise.resolve(); - }); -} - -describe("BulkDownloadQrDialog", () => { - it("refetches with the current params on a second download after filters change", async () => { - const user = userEvent.setup(); - const { store } = renderDialog(); - - /* ---------- Download #1: category A, assets a1 + a2 ---------- */ - await setFilterAndSelection(store, "category=cat-A", ["a1", "a2"]); - - await user.click(await screen.findByRole("button", { name: "Download" })); - await screen.findByText(/successfully downloaded qr codes/i); - - expect(fetchSpy).toHaveBeenCalledTimes(1); - const firstUrl = fetchSpy.mock.calls[0][0] as string; - expect(firstUrl).toContain("category=cat-A"); - expect(firstUrl).toContain("assetIds=a1"); - expect(firstUrl).toContain("assetIds=a2"); - - /* ---------- Close to re-show the Download button (state persists) ---------- */ - await user.click(screen.getByRole("button", { name: "Close" })); - - /* ---------- Change filter to tag A, assets b9 ---------- */ - await setFilterAndSelection(store, "tag=tag-A", ["b9"]); - - /* ---------- Download #2 ---------- */ - await user.click(await screen.findByRole("button", { name: "Download" })); - - // Fixed code issues a fresh fetch for the new params; the original bug - // reused the cached response and never fetched again. - await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2)); - - const secondUrl = fetchSpy.mock.calls[1][0] as string; - expect(secondUrl).toContain("tag=tag-A"); - expect(secondUrl).toContain("assetIds=b9"); - expect(secondUrl).not.toContain("category=cat-A"); - expect(secondUrl).not.toContain("assetIds=a1"); - expect(secondUrl).not.toEqual(firstUrl); - }); - - it("ignores a slow superseded response and only zips the latest request's assets", async () => { - // Manually control fetch resolution to simulate a slow first request that - // resolves AFTER the user dismissed it and started a second download. - fetchControl.mode = "manual"; - const user = userEvent.setup(); - const { store } = renderDialog(); - - /* ---------- Download #1: category A (will resolve LATE) ---------- */ - await setFilterAndSelection(store, "category=cat-A", ["a1", "a2"]); - await user.click(await screen.findByRole("button", { name: "Download" })); - await waitFor(() => expect(fetchControl.pending).toHaveLength(1)); - - /* ---------- Dismiss the loading dialog mid-flight via the header X ---------- */ - // The header close button is not gated by the loading state (unlike the body - // Close/Download buttons), so it — like Escape/backdrop — can cancel an - // in-flight download. - await user.click(screen.getByRole("button", { name: /close dialog/i })); - - /* ---------- Download #2: tag A (the current request) ---------- */ - await setFilterAndSelection(store, "tag=tag-A", ["b9"]); - await user.click(await screen.findByRole("button", { name: "Download" })); - await waitFor(() => expect(fetchControl.pending).toHaveLength(2)); - - /* ---------- The superseded request resolves FIRST, then the current one ---------- */ - await act(async () => { - fetchControl.pending[0].resolve(payloadFor(["a1", "a2"])); - await Promise.resolve(); - }); - await act(async () => { - fetchControl.pending[1].resolve(payloadFor(["b9"])); - await Promise.resolve(); - }); - - await waitFor(() => - expect( - screen.getByText(/successfully downloaded qr codes/i) - ).toBeInTheDocument() - ); - - // Only the latest request's assets are rasterized; the stale ones never are. - expect(hoisted.renderedTitles).toContain("Asset b9"); - expect(hoisted.renderedTitles).not.toContain("Asset a1"); - expect(hoisted.renderedTitles).not.toContain("Asset a2"); - }); -}); 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 38fb3477cc..87273dfc70 100644 --- a/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx +++ b/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx @@ -1,20 +1,36 @@ -import { useState, useMemo, useCallback, useRef } 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, useRef, 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; @@ -22,305 +38,249 @@ 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 [view, setView] = useState<"choose" | "pdf">("choose"); + const [zip, setZip] = useState({ status: "idle" }); const [searchParams] = useSearchParams(); - /** - * Monotonically increasing id of the most recent download request. - * - * The dialog stays mounted while the user filters/re-selects behind it (only - * `isDialogOpen` toggles), and a download can be dismissed (header X, Escape, - * backdrop) while its fetch is still in flight. A single boolean can't tell - * which request a late response belongs to once a newer request has started, - * so every click (and every close) bumps this token; a resolution is only - * acted on while its captured id still matches the latest one. - */ - const requestIdRef = useRef(0); - - /** - * AbortController for the in-flight request, so starting a new download (or - * closing the dialog) aborts the previous fetch instead of leaving it to - * resolve and zip stale assets. - */ - const abortControllerRef = useRef(null); + // 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() { - // Supersede and abort any in-flight request so a late resolution cannot - // trigger a download after the dialog has been dismissed. - requestIdRef.current += 1; - abortControllerRef.current?.abort(); - abortControllerRef.current = null; - setDownloadState({ status: "idle" }); - 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]); - /** - * Builds the QR zip from the assets returned for THIS request and triggers the - * browser download. - * - * The freshly fetched payload is passed in as an argument and never read from - * the query cache, so the zip always contains the assets matching the request - * the user just made — not a response cached from a previous filter/selection. - * - * @param data - Asset + QR payload for the current selection/filters - * @param requestId - Token of the request that produced `data`; the browser - * download is skipped if a newer request has since superseded this one. - */ - const processDownload = useCallback( - async (data: BulkQrDownloadLoaderData, requestId: number) => { - try { - const { assets, qrIdDisplayPreference, showShelfBranding } = data; - - 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" }); - - // A newer request may have superseded this one while we were - // rasterizing; if so, drop this download silently. - if (requestId !== requestIdRef.current) return; - - 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) { - // A superseded request must not clobber the UI with its own error. - if (requestId !== requestIdRef.current) return; - setDownloadState({ - status: "error", - error: - error instanceof Error ? error.message : "Something went wrong.", - }); - } - }, - [] - ); - - /** - * Starts a download for the CURRENT selection/filters. - * - * Each click supersedes any in-flight request (bumping the token and aborting - * the previous fetch) and fetches fresh data directly, so a slower earlier - * response can never be processed in place of this one. We fetch imperatively - * here — rather than via `useApiQuery` — precisely because this flow needs - * per-request cancellation that a shared, cache-retaining query hook can't - * provide. - */ - async function handleBulkDownloadQr() { - if (isSelectingMoreThan100 || !apiSearchParams) { - return; - } + const { data, isLoading } = useApiQuery({ + api: "/api/assets/get-assets-for-bulk-qr-download", + searchParams: apiSearchParams, + // Don't even fetch for free users — the loader would 403; show the upsell instead. + enabled: isDialogOpen && canExportAssets && !!apiSearchParams, + }); - // Supersede any in-flight request before starting a fresh one. - requestIdRef.current += 1; - const requestId = requestIdRef.current; - abortControllerRef.current?.abort(); - const controller = new AbortController(); - abortControllerRef.current = controller; + // Bumped on every close so an in-flight zip build that resolves AFTER the + // dialog was dismissed can't trigger a stray download or flip state to "done". + const buildTokenRef = useRef(0); - setDownloadState({ status: "loading" }); + function handleClose() { + buildTokenRef.current += 1; + setView("choose"); + setZip({ status: "idle" }); + onClose(); + } + async function downloadSvgZip() { + if (!data) return; + const token = buildTokenRef.current; + setZip({ status: "building" }); try { - const response = await fetch( - `/api/assets/get-assets-for-bulk-qr-download?${apiSearchParams.toString()}`, - { signal: controller.signal } - ); - const data = (await response.json()) as BulkQrDownloadLoaderData; - - // Ignore the response if a newer request superseded this one in flight. - if (requestId !== requestIdRef.current) { - return; - } - - await processDownload(data, requestId); - } catch (error) { - // Aborted/superseded requests resolve here too; only the latest one may - // surface an error. - if (requestId !== requestIdRef.current) { - return; - } - 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" }); + // Closed mid-build: abandon silently rather than download after dismissal. + if (token !== buildTokenRef.current) return; + 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) { + if (token !== buildTokenRef.current) return; + setZip({ status: "error", - error: error instanceof Error ? error.message : "Something went wrong.", + error: cause instanceof Error ? cause.message : "Something went wrong.", }); } } + // 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); + + // Only read `data.assets.length` once we know `data` is a success payload — + // an error payload is truthy but has no `assets`, so `data?.assets` alone + // would throw on `.length` before the `hasAssets` guard below can apply. + const count = allAssetsSelected + ? hasAssets + ? data!.assets.length + : 0 + : selectedAssets.length; + + const apiErrorMessage = + data && !hasAssets + ? (data as { error?: { message?: string } }).error?.message ?? + "Something went wrong preparing the labels." + : null; + 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…

- ) : ( + ) : apiErrorMessage ? ( +
+

{apiErrorMessage}

+ +
+ ) : 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-card.tsx b/apps/webapp/app/components/assets/qr-label-card.tsx new file mode 100644 index 0000000000..e4378b284e --- /dev/null +++ b/apps/webapp/app/components/assets/qr-label-card.tsx @@ -0,0 +1,55 @@ +/** + * QrLabelCard — the ONE QR label. + * + * Renders the exact same vector label (`buildLabelSvg`) the single download and + * the SVG zip produce, as a single ``. Used by the asset-page preview, the + * print path, and the PDF sheet cells — so preview == print == download == zip, + * byte-for-byte. One template, no drift. + * + * @see {@link file://./../../modules/qr/label.ts} + */ +import { useMemo } from "react"; +import type { CSSProperties } from "react"; +import { labelSvgDataUrl } from "~/modules/qr/label"; + +type QrLabelCardProps = { + /** The scan URL the QR encodes. */ + url: string; + /** Asset name (top-truncated in the SVG if very long). */ + title: string; + /** Resolver-driven identifier text shown under the QR. */ + idText: string; + /** Effective (tier-gated) branding flag. */ + showBranding: boolean; + /** Rendered width (CSS) — height follows the label's aspect ratio. */ + width?: string; + className?: string; + style?: CSSProperties; +}; + +/** + * @returns an `` of the vector label, sharp at any size. + */ +export function QrLabelCard({ + url, + title, + idText, + showBranding, + width = "100%", + className, + style, +}: QrLabelCardProps) { + const src = useMemo( + () => labelSvgDataUrl({ url, title, idText, showBranding }), + [url, title, idText, showBranding] + ); + + return ( + {`QR + ); +} 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..34a4af0650 --- /dev/null +++ b/apps/webapp/app/components/assets/qr-label-sheet.test.tsx @@ -0,0 +1,105 @@ +/** + * QrLabelSheet — render + print-CSS tests (RTL / happy-dom). + * + * Each cell is one `` — a vector `` of `buildLabelSvg` (the + * SAME artifact the download/zip produce). So we assert one labelled card per + * asset + the print CSS, rather than inline DOM text. + */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { QrLabelSheet } from "./qr-label-sheet"; + +/** Decode a QrLabelCard's `data:image/svg+xml;utf8,...` src back to the SVG. */ +const decodeCardSvg = (img: Element): string => + decodeURIComponent( + (img.getAttribute("src") || "").replace(/^data:[^,]+,/, "") + ); + +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 label card per asset (alt carries the name)", () => { + renderSheet(); + expect(screen.getByAltText("QR label for MacBook Pro 16")).toBeTruthy(); + expect(screen.getByAltText("QR label for Lock Washer")).toBeTruthy(); + expect(screen.getByAltText("QR label for Sony FX6")).toBeTruthy(); + }); + + it("A2 — each card is a VECTOR svg image (rects + the name/id inside)", () => { + const { container } = renderSheet(); + const cards = container.querySelectorAll('img[src^="data:image/svg+xml"]'); + expect(cards.length).toBe(3); + const svg = decodeCardSvg(cards[0]); + expect(svg).toContain(" { + 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("segmented controls expose the active option via aria-pressed", () => { + renderSheet(); + // Medium is the default size; its button must be marked pressed for SR users. + const medium = screen.getByRole("button", { name: /Medium/ }); + const small = screen.getByRole("button", { name: /Small/ }); + expect(medium.getAttribute("aria-pressed")).toBe("true"); + expect(small.getAttribute("aria-pressed")).toBe("false"); + + fireEvent.click(small); + expect(small.getAttribute("aria-pressed")).toBe("true"); + expect(medium.getAttribute("aria-pressed")).toBe("false"); + }); + + it("branding inside the card follows showBranding", () => { + const on = renderSheet(true); + const onCard = on.container.querySelector( + 'img[src^="data:image/svg+xml"]' + )!; + expect(decodeCardSvg(onCard)).toContain("Powered by shelf.nu"); + on.unmount(); + + const off = renderSheet(false); + const offCard = off.container.querySelector( + 'img[src^="data:image/svg+xml"]' + )!; + expect(decodeCardSvg(offCard)).not.toContain("Powered by shelf.nu"); + }); +}); 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..ab93c39f29 --- /dev/null +++ b/apps/webapp/app/components/assets/qr-label-sheet.tsx @@ -0,0 +1,249 @@ +/** + * 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-label-card.tsx} + * @see {@link file://./bulk-download-qr-dialog.tsx} + */ +import { useEffect, useRef, useState } from "react"; +import { useReactToPrint } from "react-to-print"; +import { QrLabelCard } from "~/components/assets/qr-label-card"; +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 +
+ +
+
+
+ QR 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) => ( +
+ {/* The ONE label template (same as the download/zip), sized in + mm for print but capped to the column on screen. */} + +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/code-preview/code-preview.tsx b/apps/webapp/app/components/code-preview/code-preview.tsx index 8d425651a8..761c2c597a 100644 --- a/apps/webapp/app/components/code-preview/code-preview.tsx +++ b/apps/webapp/app/components/code-preview/code-preview.tsx @@ -4,13 +4,17 @@ import type { BarcodeType } from "@prisma/client"; import { changeDpiDataUrl } from "changedpi"; import { toPng } from "html-to-image"; import { useReactToPrint } from "react-to-print"; +import { QrLabelCard } from "~/components/assets/qr-label-card"; 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 { resolveDisplayCode } from "~/modules/barcode/display"; +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 +33,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?: { @@ -62,12 +68,7 @@ const LABEL_CONTAINER_STYLE: CSSProperties = { backgroundColor: "white", }; -/** QR labels are square; barcode labels have a minimum height instead. */ -const QR_LABEL_STYLE: CSSProperties = { - ...LABEL_CONTAINER_STYLE, - aspectRatio: "1 / 1", -}; - +/** Barcode labels have a minimum height (QR labels render via ``). */ const BARCODE_LABEL_STYLE: CSSProperties = { ...LABEL_CONTAINER_STYLE, minHeight: "300px", @@ -99,6 +100,7 @@ interface CodePreviewProps { size: SizeKeys; id: string; src: string; + url: string; }; }; barcodes?: Array<{ @@ -110,6 +112,8 @@ interface CodePreviewProps { selectedBarcodeId?: string; onRefetchData?: () => void; // Callback to refetch data when barcode is added sequentialId?: string | null; + /** Per-asset display-code override, so the resolver matches list/bulk views. */ + preferredBarcodeId?: string | null; showShelfBranding?: boolean; } @@ -125,9 +129,10 @@ export const CodePreview = ({ selectedBarcodeId, onRefetchData, sequentialId, + preferredBarcodeId, showShelfBranding, }: CodePreviewProps) => { - const captureDivRef = useRef(null); + const captureDivRef = useRef(null); const downloadBtnRef = useRef(null); const { canUseBarcodes } = useBarcodePermissions(); const { isBaseOrSelfService, isOwner } = useUserRoleHelper(); @@ -136,6 +141,28 @@ export const CodePreview = ({ showShelfBranding, organization?.showShelfBranding ); + + // Identifier text under the QR — resolved with the SAME shared resolver as the + // list views and the bulk export, so single-item and bulk never diverge (e.g. + // for barcode-preference workspaces or per-asset display-code overrides). + const resolvedIdText = useMemo(() => { + const qrId = qrObj?.qr?.id; + if (!qrId) return ""; + return ( + resolveDisplayCode({ + entity: { + sequentialId, + qrCodes: [{ id: qrId }], + barcodes, + preferredBarcodeId, + }, + organization: { + qrIdDisplayPreference: organization?.qrIdDisplayPreference ?? "QR_ID", + barcodesEnabled: organization?.barcodesEnabled ?? false, + }, + }).value || qrId + ); + }, [qrObj, sequentialId, barcodes, preferredBarcodeId, organization]); const [isAddBarcodeDialogOpen, setIsAddBarcodeDialogOpen] = useState(false); // Build available codes list @@ -151,6 +178,7 @@ export const CodePreview = ({ qrData: { size: qrObj.qr.size, src: qrObj.qr.src, + url: qrObj.qr.url, }, }); } @@ -246,6 +274,32 @@ 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 = resolvedIdText || 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; @@ -366,14 +420,18 @@ export const CodePreview = ({ {/* Code Preview */}
{selectedCode?.type === "qr" ? ( - + className="flex w-[260px] flex-col items-center rounded border bg-white p-4" + > + +
) : selectedCode?.type === "barcode" ? ( ( - function QrLabel(props, ref) { - const { - data, - title, - qrIdDisplayPreference, - sequentialId, - showShelfBranding = true, - } = props ?? {}; - return ( -
-
{title}
-
- {`${data?.qr?.size}-shelf-qr-code.png`} -
-
-
- {qrIdDisplayPreference === "SAM_ID" && sequentialId - ? sequentialId - : data?.qr?.id} -
- {showShelfBranding ? ( -
- Powered by{" "} - shelf.nu -
- ) : null} -
-
- ); - } -); - // Barcode Label Component (new) interface BarcodeLabelProps { data?: { diff --git a/apps/webapp/app/hooks/use-api-query.test.ts b/apps/webapp/app/hooks/use-api-query.test.ts index 27e465a955..04a8f65c8a 100644 --- a/apps/webapp/app/hooks/use-api-query.test.ts +++ b/apps/webapp/app/hooks/use-api-query.test.ts @@ -64,7 +64,10 @@ describe("useApiQuery", () => { ); expect(result.current.isLoading).toBe(true); - expect(mockFetch).toHaveBeenCalledWith("/api/test"); + expect(mockFetch).toHaveBeenCalledWith( + "/api/test", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); await waitForAsyncUpdate(() => { expect(result.current.isLoading).toBe(false); @@ -93,7 +96,10 @@ describe("useApiQuery", () => { ); await waitForAsyncUpdate(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/assets?page=1&limit=10"); + expect(mockFetch).toHaveBeenCalledWith( + "/api/assets?page=1&limit=10", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); }); }); @@ -136,6 +142,33 @@ describe("useApiQuery", () => { expect(result.current.data).toBeUndefined(); }); + it("clears a prior error when a refetch succeeds", async () => { + const mockData = { id: 1, name: "First" }; + mockFetch + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValueOnce(mockData), + }); + + const { result } = renderHook(() => + useApiQuery({ api: "/api/test", enabled: true }) + ); + + await waitForAsyncUpdate(() => { + expect(result.current.error).toBe("Network error"); + }); + + act(() => { + result.current.refetch(); + }); + + await waitForAsyncUpdate(() => { + expect(result.current.data).toEqual(mockData); + }); + // The stale error must not linger after a successful refetch. + expect(result.current.error).toBeUndefined(); + }); + it("should refetch when refetch is called", async () => { const mockData1 = { id: 1, name: "First" }; const mockData2 = { id: 2, name: "Second" }; @@ -191,14 +224,20 @@ describe("useApiQuery", () => { ); await waitForAsyncUpdate(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/test1"); + expect(mockFetch).toHaveBeenCalledWith( + "/api/test1", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); }); // Change the API endpoint rerender({ api: "/api/test2" }); await waitForAsyncUpdate(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/test2"); + expect(mockFetch).toHaveBeenCalledWith( + "/api/test2", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); }); expect(mockFetch).toHaveBeenCalledTimes(2); @@ -229,14 +268,20 @@ describe("useApiQuery", () => { ); await waitForAsyncUpdate(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/test?page=1"); + expect(mockFetch).toHaveBeenCalledWith( + "/api/test?page=1", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); }); // Change search params rerender({ searchParams: searchParams2 }); await waitForAsyncUpdate(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/test?page=2"); + expect(mockFetch).toHaveBeenCalledWith( + "/api/test?page=2", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); }); expect(mockFetch).toHaveBeenCalledTimes(2); @@ -266,7 +311,10 @@ describe("useApiQuery", () => { rerender({ enabled: true }); await waitForAsyncUpdate(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/test"); + expect(mockFetch).toHaveBeenCalledWith( + "/api/test", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); }); expect(mockFetch).toHaveBeenCalledTimes(1); @@ -286,7 +334,10 @@ describe("useApiQuery", () => { ); await waitForAsyncUpdate(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/health"); + expect(mockFetch).toHaveBeenCalledWith( + "/api/health", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); }); }); diff --git a/apps/webapp/app/hooks/use-api-query.ts b/apps/webapp/app/hooks/use-api-query.ts index 746edb68db..b75fc959b2 100644 --- a/apps/webapp/app/hooks/use-api-query.ts +++ b/apps/webapp/app/hooks/use-api-query.ts @@ -44,23 +44,43 @@ export default function useApiQuery({ useEffect( function handleQuery() { - if (enabled) { - setIsLoading(true); - fetch(apiUrl) - .then((response) => response.json()) - .then((data: TData) => { - setData(data); - onSuccess?.(data); - }) - .catch((error: Error) => { - const errorMessage = error?.message ?? "Something went wrong."; - setError(errorMessage); - onError?.(errorMessage); - }) - .finally(() => { - setIsLoading(false); - }); - } + if (!enabled) return; + + // Guard against out-of-order responses: the consumer may stay mounted + // while `apiUrl` changes (e.g. selection/filter changes behind a dialog), + // so a slower earlier request could otherwise resolve last and overwrite + // the newer one. The cleanup marks this run stale and aborts its fetch, so + // only the latest request is allowed to set state. + let ignore = false; + const controller = new AbortController(); + + setIsLoading(true); + // Clear any prior error so a successful refetch doesn't leave a stale + // message visible to consumers. + setError(undefined); + fetch(apiUrl, { signal: controller.signal }) + .then((response) => response.json()) + .then((data: TData) => { + if (ignore) return; + setData(data); + onSuccess?.(data); + }) + .catch((error: Error) => { + // A superseded/aborted request is expected — never surface it. + if (ignore || error?.name === "AbortError") return; + const errorMessage = error?.message ?? "Something went wrong."; + setError(errorMessage); + onError?.(errorMessage); + }) + .finally(() => { + if (ignore) return; + setIsLoading(false); + }); + + return () => { + ignore = true; + controller.abort(); + }; }, [apiUrl, enabled, refetchTrigger, onSuccess, onError] ); diff --git a/apps/webapp/app/modules/asset/data.server.ts b/apps/webapp/app/modules/asset/data.server.ts index de276504e1..422bef90c8 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 { @@ -293,6 +293,7 @@ export async function simpleModeLoader({ modelName, hasActiveFilters, canImportAssets: canImportAssets(tierLimit) && canImport, + canExportAssets: canExportAssets(tierLimit), searchFieldLabel: "Search assets", searchFieldTooltip: { title: "Search your asset database", @@ -579,6 +580,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..52d7ca3339 --- /dev/null +++ b/apps/webapp/app/modules/qr/label.test.ts @@ -0,0 +1,194 @@ +// @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", () => { + // The sharp rasterize + jsQR decode is CPU-heavy and can exceed the default 5s + // timeout when the suite runs fully parallel; give the roundtrip room. + 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); + }, 20000); + + 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); + }, 20000); + + 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("truncates a very long title with an ellipsis (SVG text can't wrap)", () => { + const svg = buildLabelSvg({ + url: "https://eam.sh/x", + title: "Crestron AV Over IP DM 4K Net E/D w/Sim Inputs", + idText: "SAM-0599", + showBranding: true, + }); + expect(svg).toContain("…"); + expect(svg).not.toContain("w/Sim Inputs"); // tail dropped + }); + + 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"""'); + }); + + it("A14b — a formula-prefixed name is neutralized against CSV injection", () => { + for (const lead of ["=", "+", "-", "@"]) { + const csv = buildManifestCsv([asset({ title: `${lead}cmd()` })], base); + // apostrophe-prefixed so spreadsheets treat it as text, then quoted. + expect(csv).toContain(`"'${lead}cmd()"`); + } + }); + + it("A14c — a control-char-prefixed name (tab/CR/LF) is neutralized too", () => { + for (const lead of ["\t", "\r", "\n"]) { + const csv = buildManifestCsv([asset({ title: `${lead}=cmd()` })], base); + expect(csv).toContain(`"'${lead}=cmd()"`); + } + }); +}); + +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..e75b81392f --- /dev/null +++ b/apps/webapp/app/modules/qr/label.ts @@ -0,0 +1,310 @@ +/** + * 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; + + // Collect rects in an array and join once: across a bulk export (up to 1500 + // labels) repeated string concatenation in this nested loop is a needless + // CPU/memory hotspot in the browser. + const rectParts: string[] = []; + for (let r = 0; r < count; r++) { + for (let c = 0; c < count; c++) { + if (dark[r][c]) { + rectParts.push( + `` + ); + } + } + } + const rects = rectParts.join(""); + + const brand = showBranding + ? `Powered by shelf.nu` + : ""; + + // SVG doesn't wrap; truncate long names so they don't overflow the card. + const titleText = + title.length > 21 ? `${title.slice(0, 20).trimEnd()}…` : title; + + return ( + `` + + `` + + `${rects}` + + `${escapeXml( + titleText + )}` + + `${escapeXml( + idText + )}` + + brand + + `` + ); +} + +/** + * Same label as {@link buildLabelSvg}, as a vector `data:` URL — so React + * surfaces (preview, print, the PDF sheet) can render the EXACT same artifact + * the download/zip produce, via a single ``. One template, zero drift. + * + * @returns `data:image/svg+xml;utf8,` + */ +export const labelSvgDataUrl = ( + args: Parameters[0] +): string => + `data:image/svg+xml;utf8,${encodeURIComponent(buildLabelSvg(args))}`; + +/** + * RFC-4180 escape + spreadsheet-formula-injection neutralization. A cell that + * starts with `=`, `+`, `-`, `@`, or a control char can execute as a formula + * when the CSV is opened in Excel/Sheets — and asset names are attacker- + * controllable — so we prefix those with an apostrophe before quoting. + */ +const csvCell = (value: string): string => { + // Leading control chars (tab/CR/LF) can also smuggle a formula payload, so + // neutralize them alongside the `= + - @` formula triggers. + const safe = /^[=+\-@\t\r\n]/.test(value) ? `'${value}` : value; + return `"${safe.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/_layout+/assets.$assetId.overview.tsx b/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx index 954d96723a..2e55e42438 100644 --- a/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx +++ b/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx @@ -1919,6 +1919,7 @@ export default function AssetOverview() { type: "asset", }} sequentialId={asset.sequentialId} + preferredBarcodeId={asset.preferredBarcodeId} /> )} `. + * - **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,73 @@ 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 }, + // Bound the work: never load more than the cap (+1 to still detect overflow), + // so a huge select-all doesn't load the whole inventory just to be rejected. + take: MAX_BULK_QR_EXPORT + 1, }); - 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.", + status: 400, + 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( + new ShelfError({ + cause: null, + message: `Asset ${asset.id} has no QR code; excluded from QR export.`, + additionalData: { assetId: asset.id, organizationId }, + label: "Assets", + shouldBeCaptured: false, + }) + ); + 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..bf0624c66e --- /dev/null +++ b/apps/webapp/app/utils/svg-to-png.ts @@ -0,0 +1,58 @@ +/** + * 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.]+)"/); + if (!vb) { + // Fail loud rather than emit a distorted 1x1-derived PNG. + throw new Error( + 'svgToPngBlob: SVG is missing a `viewBox="0 0 W H"` — cannot size the raster.' + ); + } + const w = parseFloat(vb[1]); + const h = parseFloat(vb[2]); + 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 3fecd34e59..65acf12e4c 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -177,6 +177,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/components/code-preview/labels.test.tsx b/apps/webapp/test/components/code-preview/labels.test.tsx index 90268d2d45..dc2e9a24d8 100644 --- a/apps/webapp/test/components/code-preview/labels.test.tsx +++ b/apps/webapp/test/components/code-preview/labels.test.tsx @@ -5,39 +5,10 @@ vi.mock("lottie-react", () => ({ default: () => null, })); -import { BarcodeLabel, QrLabel } from "~/components/code-preview/code-preview"; +import { BarcodeLabel } from "~/components/code-preview/code-preview"; -describe("QrLabel", () => { - const baseProps = { - title: "Camera", - data: { - qr: { - id: "qr-123", - src: "data:image/png;base64,AAA", - size: "small", - }, - }, - } as const; - - it("shows Shelf branding by default", () => { - render(); - - expect(screen.getByText(/Powered by/i)).toBeInTheDocument(); - }); - - it("hides Shelf branding when requested", () => { - render( - - ); - - expect(screen.queryByText(/Powered by/i)).not.toBeInTheDocument(); - }); -}); +// The QR label now renders via (a single vector of +// buildLabelSvg); its content/branding is covered by label.test.ts. describe("BarcodeLabel", () => { const baseProps = { 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..47e4bdc086 --- /dev/null +++ b/apps/webapp/test/routes-tests/qr-label-export.test.ts @@ -0,0 +1,322 @@ +// @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: vi.fn(({ organizationId }: any) => ({ organizationId })), +})); +vi.mock("~/utils/subscription.server", () => ({ + canHideShelfBranding: vi.fn(), + assertUserCanExportAssets: vi.fn(), +})); +vi.mock("~/utils/logger", () => ({ + // handledClientError is invoked by http.server's error() path; stub it too so + // the 4xx/error branches don't blow up on a missing Logger method. + Logger: { warn: vi.fn(), error: vi.fn(), handledClientError: vi.fn() }, +})); + +const { loader, MAX_BULK_QR_EXPORT } = await import( + "~/routes/api+/assets.get-assets-for-bulk-qr-download" +); +const { db } = await import("~/database/db.server"); +const { getAssetsWhereInput } = await import("~/modules/asset/utils.server"); +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[], extraQuery = "") { + const params = assetIds + .map((id) => `assetIds=${encodeURIComponent(id)}`) + .join("&"); + // extraQuery carries real index filters (e.g. `s=laptop`) so select-all tests + // can verify the filters are forwarded, not just the magic key. + const query = [extraQuery, params].filter(Boolean).join("&"); + const args: any = { + context: { getSession: () => ({ userId: "user-1" }) }, + request: new Request( + `https://x/api/assets/get-assets-for-bulk-qr-download?${query}` + ), + 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("forwards the active filters to getAssetsWhereInput on select-all", async () => { + await callLoader([ALL_SELECTED], "s=laptop&category=cat-A"); + expect(getAssetsWhereInput).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: "org-1", + // The real index filters — not just the ALL_SELECTED key — must reach + // the where-builder, otherwise select-all would ignore the user's filter. + currentSearchParams: expect.stringContaining("s=laptop"), + }) + ); + }); + + it("bounds the query with take so a huge select-all isn't fully loaded", async () => { + await callLoader([ALL_SELECTED]); + expect(db.asset.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: MAX_BULK_QR_EXPORT + 1 }) + ); + }); + + 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 2378a927f6..6fbfe0a619 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,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) @@ -7990,6 +7993,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'} @@ -19039,6 +19045,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