From 5384ea62be83c7f0cb1d289fda29076c61c85670 Mon Sep 17 00:00:00 2001 From: Raf Mevis Date: Thu, 5 Mar 2026 18:02:41 +0100 Subject: [PATCH 1/7] Fixed 8 bugs from Linear issues. --- .../(dashboard)/(main)/theme-editor/page.tsx | 9 + .../forms/passport/blocks/materials-block.tsx | 223 +++++++++++++----- .../components/passports/table-section.tsx | 2 +- .../panel/views/typography-editor.tsx | 167 ++++++++++++- .../theme-editor/registry/component-tree.ts | 7 + apps/app/src/hooks/use-passport-form.ts | 28 ++- apps/app/src/hooks/use-variant-form.ts | 29 ++- apps/dpp/src/demo-data/config.ts | 2 + apps/storage/vercel.json | 18 ++ packages/db/src/defaults/theme-defaults.ts | 2 + .../src/components/carousel/product-card.tsx | 9 +- .../src/components/cta/cta-banner.tsx | 11 +- .../src/components/layout/footer.tsx | 5 + .../src/components/layout/header.tsx | 5 + .../components/layout/information-frame.tsx | 7 +- .../src/components/layout/product-image.tsx | 21 +- .../components/materials/materials-frame.tsx | 111 +++++---- .../components/product/product-details.tsx | 53 ++++- .../dpp-components/src/lib/google-fonts.ts | 5 +- .../dpp-components/src/types/theme-config.ts | 2 + 20 files changed, 577 insertions(+), 139 deletions(-) create mode 100644 apps/storage/vercel.json diff --git a/apps/app/src/app/(dashboard)/(main)/theme-editor/page.tsx b/apps/app/src/app/(dashboard)/(main)/theme-editor/page.tsx index 27c85559..948df4e9 100644 --- a/apps/app/src/app/(dashboard)/(main)/theme-editor/page.tsx +++ b/apps/app/src/app/(dashboard)/(main)/theme-editor/page.tsx @@ -11,7 +11,16 @@ export const metadata: Metadata = { export default async function Page() { await connection(); + // Prefetch theme editor and header data for hydration on refresh. prefetch(trpc.brand.theme.get.queryOptions()); + prefetch(trpc.notifications.getUnreadCount.queryOptions()); + prefetch( + trpc.notifications.getRecent.queryOptions({ + limit: 30, + unreadOnly: false, + includeDismissed: false, + }), + ); return ( diff --git a/apps/app/src/components/forms/passport/blocks/materials-block.tsx b/apps/app/src/components/forms/passport/blocks/materials-block.tsx index 0ae6f154..a5f9fab7 100644 --- a/apps/app/src/components/forms/passport/blocks/materials-block.tsx +++ b/apps/app/src/components/forms/passport/blocks/materials-block.tsx @@ -1,5 +1,9 @@ "use client"; +/** + * Materials block for passport forms. + * Handles row editing, selection, percentages, and total validation feedback. + */ import { useBrandCatalog } from "@/hooks/use-brand-catalog"; import { countries as countryData } from "@v1/selections/countries"; import { Button } from "@v1/ui/button"; @@ -31,6 +35,43 @@ interface Material { percentage: string; } +const MAX_PERCENTAGE_DECIMALS = 2; +const PERCENTAGE_PRECISION_FACTOR = 10 ** MAX_PERCENTAGE_DECIMALS; + +function roundPercentage(value: number): number { + // Keep percentage math stable to avoid floating-point artifacts. + if (!Number.isFinite(value)) return 0; + return ( + Math.round((value + Number.EPSILON) * PERCENTAGE_PRECISION_FACTOR) / + PERCENTAGE_PRECISION_FACTOR + ); +} + +function formatPercentageForDisplay(value: number): string { + // Show up to two decimals and strip trailing zeros. + const rounded = roundPercentage(value); + if (Object.is(rounded, -0)) return "0"; + return rounded + .toFixed(MAX_PERCENTAGE_DECIMALS) + .replace(/\.?0+$/, ""); +} + +function parsePercentageFromInput(value: string): number { + // Parse percentage text, including dot-prefixed values like ".7". + const trimmed = value.trim(); + if (!trimmed || trimmed === ".") return 0; + const normalized = trimmed.startsWith(".") ? `0${trimmed}` : trimmed; + const parsed = Number.parseFloat(normalized); + return Number.isFinite(parsed) ? roundPercentage(parsed) : 0; +} + +function normalizePercentageInput(value: string): string { + // Normalize free-form text on blur so user-entered values are consistent. + const trimmed = value.trim(); + if (!trimmed || trimmed === ".") return ""; + return formatPercentageForDisplay(parsePercentageFromInput(trimmed)); +} + const MaterialDropdown = ({ material, onMaterialChange, @@ -44,11 +85,13 @@ const MaterialDropdown = ({ availableMaterials: Array<{ id: string; name: string }>; excludeMaterialIds?: string[]; }) => { + // Manage searchable material selection in the row popover. const [dropdownOpen, setDropdownOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(""); const [pendingSelectedId, setPendingSelectedId] = React.useState< string | null >(null); + const searchInputRef = React.useRef(null); // Filter out materials that are already added const filteredMaterials = React.useMemo(() => { @@ -69,6 +112,15 @@ const MaterialDropdown = ({ [filteredMaterials], ); + React.useEffect(() => { + // Focus the search field when the popover opens. + if (!dropdownOpen) return; + const animationFrame = requestAnimationFrame(() => { + searchInputRef.current?.focus(); + }); + return () => cancelAnimationFrame(animationFrame); + }, [dropdownOpen]); + const handleSelect = (selectedMaterial: string) => { const selected = availableMaterials.find( (m) => m.name === selectedMaterial, @@ -106,12 +158,12 @@ const MaterialDropdown = ({ type="button" onClick={() => setDropdownOpen(!dropdownOpen)} className={cn( - "group w-full h-full px-4 py-2 flex items-center cursor-pointer transition-all", + "group w-full h-full min-w-0 px-4 py-2 flex items-center text-left cursor-pointer transition-all", )} >
handleSelect(option)} className="justify-between" > - {option} + {option} {isSelected && ( )} @@ -155,10 +208,11 @@ const MaterialDropdown = ({ -
+
- + Create "{searchQuery.trim()}"
@@ -176,6 +230,7 @@ const MaterialDropdown = ({ }; const CountryTags = ({ countries }: { countries: string[] }) => { + // Render origin chips for each material row. return (
{countries.map((countryCode) => { @@ -200,11 +255,14 @@ const PercentageCell = ({ percentage, onPercentageChange, onDelete, + onFocusChange, }: { percentage: string; onPercentageChange: (value: string) => void; onDelete: () => void; + onFocusChange?: (isFocused: boolean) => void; }) => { + // Render the inline percentage editor with row actions. const [isHovered, setIsHovered] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false); @@ -214,17 +272,22 @@ const PercentageCell = ({ }; const handlePercentageChange = (value: string) => { - // Coerce a single "." back to an empty string - if (value === ".") { - onPercentageChange(""); - return; - } - // Allow empty, numbers, and decimal point + // Allow empty, numbers, and decimal point while typing. if (value === "" || /^\d*\.?\d*$/.test(value)) { onPercentageChange(value); } }; + const handlePercentageBlur = (value: string) => { + // Normalize percentages after editing so ".7" becomes "0.7". + const normalizedValue = normalizePercentageInput(value); + if (normalizedValue !== value) { + onPercentageChange(normalizedValue); + } + setIsFocused(false); + onFocusChange?.(false); + }; + return (
handlePercentageChange(e.target.value)} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} + onFocus={() => { + setIsFocused(true); + onFocusChange?.(true); + }} + onBlur={(e) => handlePercentageBlur(e.target.value)} placeholder="Value" className="h-full w-full rounded-none border-0 bg-transparent type-p pl-8 pr-10 focus-visible:ring-[1.5px] focus-visible:ring-brand" /> @@ -264,6 +330,8 @@ const PercentageCell = ({
{(showTitle || showPrice) && ( diff --git a/packages/dpp-components/src/components/cta/cta-banner.tsx b/packages/dpp-components/src/components/cta/cta-banner.tsx index 03708c9e..0a27d8a8 100644 --- a/packages/dpp-components/src/components/cta/cta-banner.tsx +++ b/packages/dpp-components/src/components/cta/cta-banner.tsx @@ -1,3 +1,6 @@ +/** + * CTA banner section for DPP pages. + */ import type { ThemeConfig } from "@v1/dpp-components"; import Image from "next/image"; @@ -6,6 +9,7 @@ interface Props { } export function CTABanner({ themeConfig }: Props) { + // Render the banner image with explicit quality settings for clearer visuals. const { cta } = themeConfig; // Visibility toggles - default to true if not set @@ -32,6 +36,7 @@ export function CTABanner({ themeConfig }: Props) { fill className="object-cover" sizes="100vw" + quality={90} priority={false} unoptimized={isLocalDev} /> @@ -57,7 +62,11 @@ export function CTABanner({ themeConfig }: Props) { href={cta.bannerCTAUrl} target="_blank" rel="noopener noreferrer" - className="banner__button px-lg py-sm cursor-pointer text-center" + className="banner__button inline-flex items-center justify-center self-center px-lg py-sm cursor-pointer text-center" + style={{ + boxShadow: + "inset 0 0 0 1px var(--banner-button-border-color, var(--primary))", + }} aria-label={`${cta.bannerCTAText} (opens in new tab)`} > {cta.bannerCTAText} diff --git a/packages/dpp-components/src/components/layout/footer.tsx b/packages/dpp-components/src/components/layout/footer.tsx index 6e430e89..42311341 100644 --- a/packages/dpp-components/src/components/layout/footer.tsx +++ b/packages/dpp-components/src/components/layout/footer.tsx @@ -1,3 +1,6 @@ +/** + * DPP footer with brand label and social link shortcuts. + */ import type { ThemeConfig } from "@v1/dpp-components"; interface Props { @@ -7,6 +10,7 @@ interface Props { } export function Footer({ themeConfig, brandName }: Props) { + // Render social links only when their URLs are valid and safe to open. const { social } = themeConfig; // Helper function to validate URLs @@ -27,6 +31,7 @@ export function Footer({ themeConfig, brandName }: Props) { { text: "X", url: social?.twitterUrl }, { text: "PT", url: social?.pinterestUrl }, { text: "TK", url: social?.tiktokUrl }, + { text: "YT", url: social?.youtubeUrl }, { text: "LK", url: social?.linkedinUrl }, ].filter((item) => isValidUrl(item.url ?? "")); diff --git a/packages/dpp-components/src/components/layout/header.tsx b/packages/dpp-components/src/components/layout/header.tsx index 4e47f8fc..2798affe 100644 --- a/packages/dpp-components/src/components/layout/header.tsx +++ b/packages/dpp-components/src/components/layout/header.tsx @@ -1,3 +1,6 @@ +/** + * DPP header with optional brand logo image and powered-by line. + */ import type { ThemeConfig } from "@v1/dpp-components"; import { AveleroLogo } from "@v1/ui/avelero-logo"; import Image from "next/image"; @@ -9,6 +12,7 @@ interface Props { } export function Header({ themeConfig, brandName, position = "fixed" }: Props) { + // Render a crisp logo while keeping local development image handling intact. const { branding } = themeConfig; const logoHeight = 18; @@ -51,6 +55,7 @@ export function Header({ themeConfig, brandName, position = "fixed" }: Props) { width={logoHeight * 4} className="object-contain" style={{ height: `${logoHeight}px`, width: "auto" }} + quality={90} unoptimized={isLocalDev} /> ) : ( diff --git a/packages/dpp-components/src/components/layout/information-frame.tsx b/packages/dpp-components/src/components/layout/information-frame.tsx index 44740150..4b347748 100644 --- a/packages/dpp-components/src/components/layout/information-frame.tsx +++ b/packages/dpp-components/src/components/layout/information-frame.tsx @@ -1,3 +1,6 @@ +/** + * Information frame that composes product metadata sections. + */ import type { DppData, ThemeConfig } from "@v1/dpp-components"; import { countries } from "@v1/selections"; import { ImpactFrame } from "../impact/impact-frame"; @@ -13,6 +16,7 @@ interface Props { } export function InformationFrame({ data, themeConfig }: Props) { + // Transform DPP data into display models for details, materials, and journey. const { sections } = themeConfig; const { productIdentifiers, @@ -55,7 +59,7 @@ export function InformationFrame({ data, themeConfig }: Props) { type: m.material, origin: getCountryName(m.countryOfOrigin) || m.countryOfOrigin || "", certification: m.certification?.type, - certificationUrl: undefined, // No URL in new structure + certificationUrl: m.certification?.testingInstitute?.website, })) ?? []; // Transform journey/supply chain for display - group by process step @@ -109,6 +113,7 @@ export function InformationFrame({ data, themeConfig }: Props) { +
{image ? ( {alt} ) : (
Materials ); diff --git a/packages/dpp-components/src/components/product/product-details.tsx b/packages/dpp-components/src/components/product/product-details.tsx index 54b81ddb..80c18d38 100644 --- a/packages/dpp-components/src/components/product/product-details.tsx +++ b/packages/dpp-components/src/components/product/product-details.tsx @@ -1,8 +1,12 @@ +/** + * Product details table for article metadata and variant attributes. + */ import type { ThemeConfig, VariantAttribute } from "@v1/dpp-components"; interface Props { articleNumber: string; manufacturer: string; + manufacturerUrl?: string; countryOfOrigin: string; category: string; /** Variant attributes (0-3) */ @@ -10,28 +14,53 @@ interface Props { themeConfig: ThemeConfig; } +function toCapitalizedLabel(value: string): string { + // Normalize labels so capitalization controls in the theme editor work as expected. + return value + .replace(/[_-]+/g, " ") + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(" "); +} + +function toExternalHref(url?: string): string | undefined { + // Normalize plain domain values so external links still open correctly. + const trimmed = url?.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return trimmed; + } + return `https://${trimmed}`; +} + export function ProductDetails({ articleNumber, manufacturer, + manufacturerUrl, countryOfOrigin, category, attributes = [], themeConfig, }: Props) { + // Build rows for non-empty values, including optional external links. + void themeConfig; const articleNumberClickable = true; - const manufacturerClickable = true; + const normalizedManufacturerUrl = toExternalHref(manufacturerUrl); + const manufacturerClickable = Boolean(normalizedManufacturerUrl); - // Build rows array, filtering out empty values const rows: Array<{ label: string; value: string; clickable?: boolean; href?: string; + external?: boolean; }> = []; if (articleNumber) { rows.push({ - label: "ARTICLE NUMBER", + label: "Article Number", value: articleNumber, clickable: articleNumberClickable, href: "#article-number", @@ -40,23 +69,24 @@ export function ProductDetails({ if (manufacturer) { rows.push({ - label: "MANUFACTURER", + label: "Manufacturer", value: manufacturer, clickable: manufacturerClickable, - href: "#manufacturer", + href: normalizedManufacturerUrl, + external: true, }); } if (countryOfOrigin) { rows.push({ - label: "COUNTRY OF ORIGIN", + label: "Country Of Origin", value: countryOfOrigin, }); } if (category) { rows.push({ - label: "CATEGORY", + label: "Category", value: category, }); } @@ -65,7 +95,7 @@ export function ProductDetails({ for (const attr of attributes.slice(0, 3)) { if (attr.value) { rows.push({ - label: attr.name.toUpperCase(), + label: toCapitalizedLabel(attr.name), value: attr.value, }); } @@ -87,7 +117,12 @@ export function ProductDetails({
{row.label}
{row.clickable && row.href ? ( - + {row.value} ) : ( diff --git a/packages/dpp-components/src/lib/google-fonts.ts b/packages/dpp-components/src/lib/google-fonts.ts index 3d4cc662..db0bb2f9 100644 --- a/packages/dpp-components/src/lib/google-fonts.ts +++ b/packages/dpp-components/src/lib/google-fonts.ts @@ -77,13 +77,14 @@ function formatFontFamily(font: string): string { /** * Generates a Google Fonts CSS2 URL for a list of fonts. - * Loads weights 300, 400, 500, 700 to support Light, Regular, Medium, Bold. + * Loads weights 100-900 to support the full preset typography scale. */ export function generateGoogleFontsUrl(fonts: string[]): string { if (!fonts.length) return ""; const families = fonts.map( - (font) => `family=${formatFontFamily(font)}:wght@300;400;500;700`, + (font) => + `family=${formatFontFamily(font)}:wght@100;200;300;400;500;600;700;800;900`, ); return `https://fonts.googleapis.com/css2?${families.join("&")}&display=swap`; diff --git a/packages/dpp-components/src/types/theme-config.ts b/packages/dpp-components/src/types/theme-config.ts index e3bb926a..c716a372 100644 --- a/packages/dpp-components/src/types/theme-config.ts +++ b/packages/dpp-components/src/types/theme-config.ts @@ -36,12 +36,14 @@ export interface ThemeConfig { showTwitter: boolean; showPinterest: boolean; showTiktok: boolean; + showYoutube: boolean; showLinkedin: boolean; instagramUrl: string; facebookUrl: string; twitterUrl: string; pinterestUrl: string; tiktokUrl: string; + youtubeUrl: string; linkedinUrl: string; }; From 1bda9443663c8c1539344ef2cba81a8a84ef5e0e Mon Sep 17 00:00:00 2001 From: Raf Mevis Date: Thu, 5 Mar 2026 18:50:43 +0100 Subject: [PATCH 2/7] Fixed additional bugs. --- .../src/trpc/routers/brand/theme-preview.ts | 14 +- .../app/src/components/select/font-select.tsx | 120 ++++++++++++++---- .../src/contexts/design-editor-provider.tsx | 68 +++++++--- apps/dpp/src/app/layout.tsx | 14 +- .../db/src/queries/products/publish-batch.ts | 15 ++- packages/db/src/queries/products/snapshot.ts | 15 ++- .../dpp-components/src/lib/google-fonts.ts | 58 +++++++-- packages/ui/src/components/select.tsx | 44 ++++++- 8 files changed, 278 insertions(+), 70 deletions(-) diff --git a/apps/api/src/trpc/routers/brand/theme-preview.ts b/apps/api/src/trpc/routers/brand/theme-preview.ts index d61af7bb..2eaca8fe 100644 --- a/apps/api/src/trpc/routers/brand/theme-preview.ts +++ b/apps/api/src/trpc/routers/brand/theme-preview.ts @@ -248,6 +248,7 @@ function buildDppData( categoryId: string | null; categoryName: string | null; manufacturerName: string | null; + manufacturerWebsite: string | null; manufacturerCountryCode: string | null; }, attributes: { @@ -295,7 +296,16 @@ function buildDppData( recyclable: m.recyclable ?? undefined, countryOfOrigin: getCountryName(m.countryOfOrigin), certification: m.certificationTitle - ? { type: m.certificationTitle, code: "" } + ? { + type: m.certificationTitle, + code: "", + testingInstitute: m.certificationUrl + ? { + legalName: "", + website: m.certificationUrl, + } + : undefined, + } : undefined, })), }, @@ -304,6 +314,7 @@ function buildDppData( ? { manufacturerId: 0, name: core.manufacturerName, + website: core.manufacturerWebsite ?? undefined, countryCode: core.manufacturerCountryCode ?? undefined, } : undefined, @@ -355,6 +366,7 @@ export const themePreviewRouter = createTRPCRouter({ categoryId: products.categoryId, categoryName: taxonomyCategories.name, manufacturerName: brandManufacturers.name, + manufacturerWebsite: brandManufacturers.website, manufacturerCountryCode: brandManufacturers.countryCode, }) .from(products) diff --git a/apps/app/src/components/select/font-select.tsx b/apps/app/src/components/select/font-select.tsx index bf3cf257..fb598c88 100644 --- a/apps/app/src/components/select/font-select.tsx +++ b/apps/app/src/components/select/font-select.tsx @@ -1,7 +1,10 @@ "use client"; import { useVirtualizer } from "@tanstack/react-virtual"; -import type { CustomFont } from "@v1/dpp-components"; +import { + generateGoogleFontsUrlForFont, + type CustomFont, +} from "@v1/dpp-components"; import { type FontMetadata, fonts } from "@v1/selections/fonts"; import { Button } from "@v1/ui/button"; import { cn } from "@v1/ui/cn"; @@ -27,12 +30,14 @@ const loadedFonts = new Set(); // Load a font from Google Fonts function loadGoogleFont(family: string) { if (loadedFonts.has(family)) return; - loadedFonts.add(family); + const fontUrl = generateGoogleFontsUrlForFont(family); + if (!fontUrl) return; const link = document.createElement("link"); - link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}&display=swap`; + link.href = fontUrl; link.rel = "stylesheet"; document.head.appendChild(link); + loadedFonts.add(family); } // Load a custom font via @font-face @@ -76,6 +81,10 @@ export function FontSelect({ const [open, setOpen] = React.useState(false); const [searchTerm, setSearchTerm] = React.useState(""); const inputRef = React.useRef(null); + const pendingCustomFontAutoSelect = React.useRef(false); + const customFontSourcesSnapshot = React.useRef>( + new Set(customFonts.map((font) => font.src)), + ); // Get unique custom font families (deduplicated) const customFontFamilies = React.useMemo(() => { @@ -114,6 +123,25 @@ export function FontSelect({ } }, [value, isCustomFontSelected, customFonts]); + // Auto-select the newly uploaded custom font after returning from the modal. + React.useEffect(() => { + if (!pendingCustomFontAutoSelect.current) return; + + const previousSources = customFontSourcesSnapshot.current; + const addedFonts = customFonts.filter((font) => !previousSources.has(font.src)); + customFontSourcesSnapshot.current = new Set(customFonts.map((font) => font.src)); + + if (addedFonts.length === 0) return; + + pendingCustomFontAutoSelect.current = false; + const latestAddedFont = addedFonts[addedFonts.length - 1]; + if (!latestAddedFont) return; + + if (value?.toLowerCase() !== latestAddedFont.fontFamily.toLowerCase()) { + onValueChange(latestAddedFont.fontFamily); + } + }, [customFonts, onValueChange, value]); + // Focus input when popover opens React.useEffect(() => { if (open) { @@ -129,6 +157,10 @@ export function FontSelect({ }; const handleManageCustomFonts = () => { + pendingCustomFontAutoSelect.current = true; + customFontSourcesSnapshot.current = new Set( + customFonts.map((font) => font.src), + ); setOpen(false); onManageCustomFonts?.(); }; @@ -273,43 +305,77 @@ function FontList({ // Track if we've done the initial scroll to selected item const hasScrolledToSelected = React.useRef(false); - // Scroll to selected Google font when opening (deferred to avoid flushSync during render) + // Center the selected item when opening. React.useEffect(() => { - if (!isOpen || searchTerm.trim()) { + if (!isOpen) { + hasScrolledToSelected.current = false; + return; + } + + if (searchTerm.trim()) { hasScrolledToSelected.current = false; return; } - // Skip if custom font is selected or no Google font selected - if (selectedCustomFontIndex >= 0 || selectedGoogleFontIndex < 0) return; - // Only scroll once per open if (hasScrolledToSelected.current) return; - hasScrolledToSelected.current = true; - // Use queueMicrotask to defer the scroll outside of React's render cycle - queueMicrotask(() => { - virtualizer.scrollToIndex(selectedGoogleFontIndex, { align: "center" }); + let innerFrameId = 0; + const frameId = requestAnimationFrame(() => { + innerFrameId = requestAnimationFrame(() => { + const container = scrollRef.current; + if (!container) return; + + const containerHeight = container.clientHeight; + const customRowHeight = 30; + const googleRowHeight = 32; + const sectionHeaderHeight = showSectionHeaders ? 28 : 0; + + if (selectedCustomFontIndex >= 0) { + const customOffset = + sectionHeaderHeight + selectedCustomFontIndex * customRowHeight; + const centeredTop = + customOffset - containerHeight / 2 + customRowHeight / 2; + container.scrollTop = Math.max(0, centeredTop); + hasScrolledToSelected.current = true; + return; + } + + if (selectedGoogleFontIndex >= 0) { + const customSectionHeight = hasCustomFonts + ? sectionHeaderHeight + filteredCustomFonts.length * customRowHeight + : 0; + const googleHeaderOffset = hasGoogleFonts ? sectionHeaderHeight : 0; + const googleRowOffset = + customSectionHeight + + googleHeaderOffset + + selectedGoogleFontIndex * googleRowHeight; + const centeredTop = + googleRowOffset - containerHeight / 2 + googleRowHeight / 2; + + container.scrollTop = Math.max(0, centeredTop); + hasScrolledToSelected.current = true; + return; + } + }); }); + + return () => { + cancelAnimationFrame(frameId); + if (innerFrameId) { + cancelAnimationFrame(innerFrameId); + } + }; }, [ isOpen, searchTerm, - selectedGoogleFontIndex, selectedCustomFontIndex, - virtualizer, + selectedGoogleFontIndex, + hasCustomFonts, + hasGoogleFonts, + filteredCustomFonts.length, + showSectionHeaders, ]); - // Scroll to selected custom font when opening (non-virtualized) - React.useEffect(() => { - if (!isOpen || searchTerm.trim()) return; - if (selectedCustomFontIndex < 0) return; - - // Immediate scroll for custom fonts section - const itemHeight = 32; - const headerHeight = showSectionHeaders ? 28 : 0; - const scrollTop = headerHeight + selectedCustomFontIndex * itemHeight; - scrollRef.current?.scrollTo({ top: Math.max(0, scrollTop - 48) }); - }, [isOpen, searchTerm, selectedCustomFontIndex, showSectionHeaders]); - // Load fonts for visible Google font items const virtualItems = virtualizer.getVirtualItems(); React.useEffect(() => { @@ -345,7 +411,7 @@ function FontList({ return (
{/* Custom Fonts Section */} diff --git a/apps/app/src/contexts/design-editor-provider.tsx b/apps/app/src/contexts/design-editor-provider.tsx index a4217a98..296d6bfd 100644 --- a/apps/app/src/contexts/design-editor-provider.tsx +++ b/apps/app/src/contexts/design-editor-provider.tsx @@ -11,6 +11,7 @@ import type { } from "@v1/dpp-components"; import { type ColorTokenKey, + generateGoogleFontsUrlFromTypography, getTokenName, isTokenReference, } from "@v1/dpp-components"; @@ -238,44 +239,75 @@ export function DesignEditorProvider({ setSavedThemeConfig(initialThemeConfig); }, [initialThemeConfig]); - // Load saved Google Fonts on mount to display typography correctly + const previewGoogleFontsUrl = useMemo(() => { + // Always prefer a canonical weighted URL generated from current typography. + const generatedUrl = generateGoogleFontsUrlFromTypography( + themeStylesDraft?.typography as Record | undefined, + ); + return generatedUrl || initialGoogleFontsUrl || ""; + }, [themeStylesDraft?.typography, initialGoogleFontsUrl]); + + // Load Google Fonts in preview to match current typography settings useEffect(() => { - if (!initialGoogleFontsUrl) return; + if (!previewGoogleFontsUrl) return; // Check if this font link already exists const existingLink = document.querySelector( - `link[href="${initialGoogleFontsUrl}"]`, + `link[href="${previewGoogleFontsUrl}"]`, ); if (existingLink) return; + // Ensure preconnects exist (reuse if already present) + let preconnect1: HTMLLinkElement | null = document.querySelector( + 'link[rel="preconnect"][href="https://fonts.googleapis.com"]', + ); + let preconnect2: HTMLLinkElement | null = document.querySelector( + 'link[rel="preconnect"][href="https://fonts.gstatic.com"]', + ); + const createdPreconnect1 = !preconnect1; + const createdPreconnect2 = !preconnect2; + // Add preconnect for faster font loading - const preconnect1 = document.createElement("link"); - preconnect1.rel = "preconnect"; - preconnect1.href = "https://fonts.googleapis.com"; - document.head.appendChild(preconnect1); + if (!preconnect1) { + preconnect1 = document.createElement("link"); + preconnect1.rel = "preconnect"; + preconnect1.href = "https://fonts.googleapis.com"; + document.head.appendChild(preconnect1); + } - const preconnect2 = document.createElement("link"); - preconnect2.rel = "preconnect"; - preconnect2.href = "https://fonts.gstatic.com"; - preconnect2.crossOrigin = "anonymous"; - document.head.appendChild(preconnect2); + if (!preconnect2) { + preconnect2 = document.createElement("link"); + preconnect2.rel = "preconnect"; + preconnect2.href = "https://fonts.gstatic.com"; + preconnect2.crossOrigin = "anonymous"; + document.head.appendChild(preconnect2); + } // Add the font stylesheet const link = document.createElement("link"); link.rel = "stylesheet"; - link.href = initialGoogleFontsUrl; + link.href = previewGoogleFontsUrl; document.head.appendChild(link); return () => { // Cleanup on unmount or URL change link.remove(); - // Only remove preconnects if no other font links exist - if (!document.querySelector('link[href*="fonts.googleapis.com/css"]')) { - preconnect1.remove(); - preconnect2.remove(); + + // Remove preconnects only if they were created by this effect and unused + if ( + !document.querySelector('link[href*="fonts.googleapis.com/css"]') && + createdPreconnect1 + ) { + preconnect1?.remove(); + } + if ( + !document.querySelector('link[href*="fonts.googleapis.com/css"]') && + createdPreconnect2 + ) { + preconnect2?.remove(); } }; - }, [initialGoogleFontsUrl]); + }, [previewGoogleFontsUrl]); // --------------------------------------------------------------------------- // Unsaved Changes Detection diff --git a/apps/dpp/src/app/layout.tsx b/apps/dpp/src/app/layout.tsx index 2ac9e65d..b7f7e577 100644 --- a/apps/dpp/src/app/layout.tsx +++ b/apps/dpp/src/app/layout.tsx @@ -86,19 +86,7 @@ export default function RootLayout({ }) { return ( - - {/* Google Fonts: Geist Sans and Geist Mono as default fonts */} - - - - + {children} ); diff --git a/packages/db/src/queries/products/publish-batch.ts b/packages/db/src/queries/products/publish-batch.ts index ec3f11a3..8f32426e 100644 --- a/packages/db/src/queries/products/publish-batch.ts +++ b/packages/db/src/queries/products/publish-batch.ts @@ -358,6 +358,19 @@ function buildMaterialsSnapshot( const certification = material.certificationId ? certificationsById.get(material.certificationId) : null; + const hasTestingInstituteData = certification + ? Boolean( + certification.instituteName || + certification.instituteEmail || + certification.instituteWebsite || + certification.instituteAddressLine1 || + certification.instituteAddressLine2 || + certification.instituteCity || + certification.instituteState || + certification.instituteZip || + certification.instituteCountryCode, + ) + : false; return { material: material.name, @@ -368,7 +381,7 @@ function buildMaterialsSnapshot( ? { title: certification.title, certificationCode: certification.certificationCode, - testingInstitute: certification.instituteName + testingInstitute: hasTestingInstituteData ? { instituteName: certification.instituteName, instituteEmail: certification.instituteEmail, diff --git a/packages/db/src/queries/products/snapshot.ts b/packages/db/src/queries/products/snapshot.ts index 24981493..74a5922e 100644 --- a/packages/db/src/queries/products/snapshot.ts +++ b/packages/db/src/queries/products/snapshot.ts @@ -376,6 +376,19 @@ async function fetchMaterialsWithCertifications( const certification = material.certificationId ? certificationsMap.get(material.certificationId) : null; + const hasTestingInstituteData = certification + ? Boolean( + certification.instituteName || + certification.instituteEmail || + certification.instituteWebsite || + certification.instituteAddressLine1 || + certification.instituteAddressLine2 || + certification.instituteCity || + certification.instituteState || + certification.instituteZip || + certification.instituteCountryCode, + ) + : false; return { material: material.name, @@ -386,7 +399,7 @@ async function fetchMaterialsWithCertifications( ? { title: certification.title, certificationCode: certification.certificationCode, - testingInstitute: certification.instituteName + testingInstitute: hasTestingInstituteData ? { instituteName: certification.instituteName, instituteEmail: certification.instituteEmail, diff --git a/packages/dpp-components/src/lib/google-fonts.ts b/packages/dpp-components/src/lib/google-fonts.ts index db0bb2f9..d766dc89 100644 --- a/packages/dpp-components/src/lib/google-fonts.ts +++ b/packages/dpp-components/src/lib/google-fonts.ts @@ -2,11 +2,9 @@ * Minimal Google Fonts URL generation utilities for ThemeStyles typography. * This intentionally avoids any browser or React dependencies so it can be used in server actions. */ +import { findFont } from "@v1/selections/fonts"; const LOCAL_FONTS = [ - "geist", - "geist sans", - "geist mono", "system-ui", "sans-serif", "monospace", @@ -16,6 +14,7 @@ const LOCAL_FONTS = [ "times", "verdana", ]; +const PRESET_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900]; /** * Checks if a font is a Google Font (not a local/system font). @@ -75,17 +74,60 @@ function formatFontFamily(font: string): string { return font.replace(/\s+/g, "+"); } +function parseVariantWeight(variant: string): number | undefined { + // Normalize static variant values such as "regular", "700", or "700italic". + const normalized = variant.toLowerCase(); + if (normalized === "regular" || normalized === "italic") { + return 400; + } + + const match = normalized.match(/\d{3}/); + if (!match?.[0]) return undefined; + + const weight = Number.parseInt(match[0], 10); + return Number.isNaN(weight) ? undefined : weight; +} + +function getFontWeights(fontFamily: string): number[] { + // Resolve the safest available weight set for each Google font family. + const metadata = findFont(fontFamily); + if (!metadata) { + return PRESET_WEIGHTS; + } + + if (metadata.isVariable) { + const weightAxis = metadata.axes.find((axis) => axis.tag === "wght"); + if (!weightAxis) { + return PRESET_WEIGHTS; + } + + return PRESET_WEIGHTS.filter( + (weight) => weight >= weightAxis.start && weight <= weightAxis.end, + ); + } + + const staticWeights = Array.from( + new Set( + (metadata.variants ?? []) + .map(parseVariantWeight) + .filter((weight): weight is number => weight !== undefined), + ), + ).sort((a, b) => a - b); + + return staticWeights.length > 0 ? staticWeights : [400]; +} + /** * Generates a Google Fonts CSS2 URL for a list of fonts. - * Loads weights 100-900 to support the full preset typography scale. + * Always includes an explicit weight axis for consistent rendering behavior. */ export function generateGoogleFontsUrl(fonts: string[]): string { if (!fonts.length) return ""; - const families = fonts.map( - (font) => - `family=${formatFontFamily(font)}:wght@100;200;300;400;500;600;700;800;900`, - ); + const families = fonts.map((font) => { + const weights = getFontWeights(font); + return `family=${formatFontFamily(font)}:wght@${weights.join(";")}`; + }); return `https://fonts.googleapis.com/css2?${families.join("&")}&display=swap`; } diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index e285a538..9cd9f725 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -60,6 +60,9 @@ const SelectContent = React.forwardRef< }, ref, ) => { + const contentRef = + React.useRef>(null); + // Track the controlled value for cmdk selection const [value, setValue] = React.useState(defaultValue ?? ""); @@ -68,6 +71,45 @@ const SelectContent = React.forwardRef< setValue(defaultValue ?? ""); }, [defaultValue]); + // Keep the currently selected cmdk item visible when the list opens. + React.useEffect(() => { + if (!value || value === "__clear__") return; + + const frameId = requestAnimationFrame(() => { + const selectedItem = + contentRef.current?.querySelector( + '[cmdk-item][data-selected="true"]', + ) ?? + contentRef.current?.querySelector( + '[cmdk-item][aria-selected="true"]', + ); + + selectedItem?.scrollIntoView({ block: "nearest" }); + }); + + return () => cancelAnimationFrame(frameId); + }, [value]); + + const handleRef = React.useCallback( + (node: React.ElementRef | null) => { + contentRef.current = node; + + if (typeof ref === "function") { + ref(node); + return; + } + + if (ref) { + ( + ref as React.MutableRefObject | null> + ).current = node; + } + }, + [ref], + ); + const contextValue = React.useMemo( () => ({ clearSelection: () => setValue("__clear__"), @@ -77,7 +119,7 @@ const SelectContent = React.forwardRef< return ( Date: Thu, 5 Mar 2026 19:27:15 +0100 Subject: [PATCH 3/7] Updated storage URL env variables. --- .github/workflows/production-jobs.yaml | 1 + apps/admin/complete.env.example | 1 + apps/admin/next.config.mjs | 14 ++- apps/admin/src/env.mjs | 5 + apps/api/src/trpc/routers/dpp-public/index.ts | 111 ++++++++++++------ apps/app/next.config.mjs | 14 ++- apps/app/src/env.mjs | 5 + apps/app/src/utils/storage-urls.ts | 16 ++- apps/storage/.gitignore | 1 + packages/db/src/utils/storage-url.ts | 71 +++++++++-- packages/jobs/src/lib/excel.ts | 96 +++++++++++++-- .../jobs/src/trigger/bulk/export-qr-codes.ts | 39 +++++- packages/jobs/trigger.config.ts | 23 ++++ packages/supabase/src/utils/storage.ts | 39 +++++- 14 files changed, 376 insertions(+), 60 deletions(-) create mode 100644 apps/storage/.gitignore diff --git a/.github/workflows/production-jobs.yaml b/.github/workflows/production-jobs.yaml index 098d0cca..76c62399 100644 --- a/.github/workflows/production-jobs.yaml +++ b/.github/workflows/production-jobs.yaml @@ -40,5 +40,6 @@ jobs: INTERNAL_API_KEY: ${{ secrets.INTERNAL_API_KEY }} DATABASE_URL: ${{ secrets.DATABASE_URL }} NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + SUPABASE_STORAGE_URL: ${{ secrets.SUPABASE_STORAGE_URL }} SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} diff --git a/apps/admin/complete.env.example b/apps/admin/complete.env.example index c71d9570..74ffed50 100644 --- a/apps/admin/complete.env.example +++ b/apps/admin/complete.env.example @@ -4,6 +4,7 @@ NEXT_PUBLIC_API_URL=http://localhost:4000 # Supabase NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY= +NEXT_PUBLIC_STORAGE_URL= # Google OAuth (for Google sign-in button) NEXT_PUBLIC_GOOGLE_CLIENT_ID= diff --git a/apps/admin/next.config.mjs b/apps/admin/next.config.mjs index cc4add5e..ec4697c9 100644 --- a/apps/admin/next.config.mjs +++ b/apps/admin/next.config.mjs @@ -1,10 +1,16 @@ +/** + * Next.js configuration for the admin frontend. + */ import "./src/env.mjs"; /** @type {import('next').NextConfig} */ const supabaseUrl = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL); +const storageUrl = new URL( + process.env.NEXT_PUBLIC_STORAGE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL, +); const isLocal = - supabaseUrl.hostname === "127.0.0.1" || supabaseUrl.hostname === "localhost"; + storageUrl.hostname === "127.0.0.1" || storageUrl.hostname === "localhost"; /** @type {import('next').NextConfig} */ const nextConfig = { @@ -13,6 +19,12 @@ const nextConfig = { images: { unoptimized: isLocal, remotePatterns: [ + { + protocol: storageUrl.protocol.replace(":", ""), + hostname: storageUrl.hostname, + port: storageUrl.port, + pathname: "/storage/**", + }, { protocol: supabaseUrl.protocol.replace(":", ""), hostname: supabaseUrl.hostname, diff --git a/apps/admin/src/env.mjs b/apps/admin/src/env.mjs index e8738770..0a37933b 100644 --- a/apps/admin/src/env.mjs +++ b/apps/admin/src/env.mjs @@ -1,3 +1,6 @@ +/** + * Environment schema and runtime bindings for the admin frontend. + */ import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; @@ -16,6 +19,7 @@ export const env = createEnv({ NEXT_PUBLIC_API_URL: z.string().min(1), NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1), NEXT_PUBLIC_SUPABASE_URL: z.string().url(), + NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(), NEXT_PUBLIC_GOOGLE_CLIENT_ID: z.string().min(1), NEXT_PUBLIC_APP_URL: z.string().url(), }, @@ -23,6 +27,7 @@ export const env = createEnv({ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, + NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL, NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, SUPABASE_SERVICE_KEY: process.env.SUPABASE_SERVICE_KEY, diff --git a/apps/api/src/trpc/routers/dpp-public/index.ts b/apps/api/src/trpc/routers/dpp-public/index.ts index 870243b9..c6740b6c 100644 --- a/apps/api/src/trpc/routers/dpp-public/index.ts +++ b/apps/api/src/trpc/routers/dpp-public/index.ts @@ -23,6 +23,8 @@ import { resolveThemeConfigImageUrls } from "../../../utils/theme-config-images. import { slugSchema } from "../../../schemas/_shared/primitives.js"; import { createTRPCRouter, publicProcedure } from "../../init.js"; +const PRODUCTS_BUCKET = "products"; + /** * UPID schema: 16-character alphanumeric identifier */ @@ -38,6 +40,70 @@ const getThemePreviewSchema = z.object({ brandSlug: slugSchema, }); +/** + * Escape regex metacharacters for a dynamic path pattern. + */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Decode URI path segments safely. + */ +function decodeStoragePath(path: string): string { + return path + .split("/") + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join("/"); +} + +/** + * Extract a bucket object path from known Supabase storage URL shapes. + */ +function extractStorageObjectPath( + value: string, + bucket: string, +): string | null { + const escapedBucket = escapeRegExp(bucket); + const pattern = new RegExp( + `(?:https?:\\/\\/[^/]+)?\\/storage\\/v1\\/object\\/(?:public|sign)\\/${escapedBucket}\\/(.+?)(?:[?#].*)?$`, + "i", + ); + const match = value.match(pattern); + if (!match?.[1]) return null; + return decodeStoragePath(match[1]); +} + +/** + * Resolve snapshot image values to a current public URL on the configured storage domain. + */ +function resolveSnapshotProductImageUrl( + storageClient: Parameters[0], + imageValue: string | null | undefined, +): string | null { + if (!imageValue) return null; + + const normalizedImage = + extractStorageObjectPath(imageValue, PRODUCTS_BUCKET) ?? imageValue; + if ( + normalizedImage.startsWith("http://") || + normalizedImage.startsWith("https://") + ) { + return normalizedImage; + } + + return ( + getPublicUrl(storageClient, PRODUCTS_BUCKET, normalizedImage) ?? + normalizedImage + ); +} + export const dppPublicRouter = createTRPCRouter({ /** * Fetch theme data for screenshot preview. @@ -115,24 +181,11 @@ export const dppPublicRouter = createTRPCRouter({ ? getPublicUrl(ctx.supabase, "dpp-themes", result.theme.stylesheetPath) : null; - // Resolve product image in the snapshot to public URL - let productImageUrl: string | null = null; - const snapshotImage = result.snapshot.productAttributes?.image; - if (snapshotImage && typeof snapshotImage === "string") { - // Check if it's already a full URL or a storage path - if ( - snapshotImage.startsWith("http://") || - snapshotImage.startsWith("https://") - ) { - productImageUrl = snapshotImage; - } else { - productImageUrl = getPublicUrl( - ctx.supabase, - "products", - snapshotImage, - ); - } - } + // Resolve product image in the snapshot to a current public URL. + const productImageUrl = resolveSnapshotProductImageUrl( + ctx.supabase, + result.snapshot.productAttributes?.image, + ); // Resolve image paths in themeConfig to full URLs const resolvedThemeConfig = resolveThemeConfigImageUrls( @@ -266,23 +319,11 @@ export const dppPublicRouter = createTRPCRouter({ ? getPublicUrl(ctx.supabase, "dpp-themes", result.theme.stylesheetPath) : null; - // Resolve product image in the snapshot to public URL - let productImageUrl: string | null = null; - const snapshotImage = result.snapshot.productAttributes?.image; - if (snapshotImage && typeof snapshotImage === "string") { - if ( - snapshotImage.startsWith("http://") || - snapshotImage.startsWith("https://") - ) { - productImageUrl = snapshotImage; - } else { - productImageUrl = getPublicUrl( - ctx.supabase, - "products", - snapshotImage, - ); - } - } + // Resolve product image in the snapshot to a current public URL. + const productImageUrl = resolveSnapshotProductImageUrl( + ctx.supabase, + result.snapshot.productAttributes?.image, + ); // Resolve image paths in themeConfig to full URLs const resolvedThemeConfig = resolveThemeConfigImageUrls( diff --git a/apps/app/next.config.mjs b/apps/app/next.config.mjs index e495d317..b37c9f77 100644 --- a/apps/app/next.config.mjs +++ b/apps/app/next.config.mjs @@ -1,10 +1,16 @@ +/** + * Next.js configuration for the app frontend. + */ import "./src/env.mjs"; /** @type {import('next').NextConfig} */ const supabaseUrl = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL); +const storageUrl = new URL( + process.env.NEXT_PUBLIC_STORAGE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL, +); const isLocal = - supabaseUrl.hostname === "127.0.0.1" || supabaseUrl.hostname === "localhost"; + storageUrl.hostname === "127.0.0.1" || storageUrl.hostname === "localhost"; /** @type {import('next').NextConfig} */ const nextConfig = { @@ -20,6 +26,12 @@ const nextConfig = { images: { unoptimized: isLocal, remotePatterns: [ + { + protocol: storageUrl.protocol.replace(":", ""), + hostname: storageUrl.hostname, + port: storageUrl.port, + pathname: "/storage/**", // allow both public and sign URLs + }, { protocol: supabaseUrl.protocol.replace(":", ""), hostname: supabaseUrl.hostname, diff --git a/apps/app/src/env.mjs b/apps/app/src/env.mjs index 67bddb87..0388fac4 100644 --- a/apps/app/src/env.mjs +++ b/apps/app/src/env.mjs @@ -1,3 +1,6 @@ +/** + * Environment schema and runtime bindings for the app frontend. + */ import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; @@ -21,12 +24,14 @@ const env = createEnv({ NEXT_PUBLIC_API_URL: z.string(), NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string(), NEXT_PUBLIC_SUPABASE_URL: z.string(), + NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(), }, runtimeEnv: { NEXT_PUBLIC_OPENPANEL_CLIENT_ID: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID, NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, + NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL, OPENPANEL_SECRET_KEY: process.env.OPENPANEL_SECRET_KEY, PORT: process.env.PORT, RESEND_API_KEY: process.env.RESEND_API_KEY, diff --git a/apps/app/src/utils/storage-urls.ts b/apps/app/src/utils/storage-urls.ts index 4cd617d5..6b498779 100644 --- a/apps/app/src/utils/storage-urls.ts +++ b/apps/app/src/utils/storage-urls.ts @@ -47,11 +47,11 @@ export function buildPublicUrl( ): string | null { if (!path) return null; - const supabaseUrl = getSupabaseUrl(); + const storageBaseUrl = getSupabaseUrl(); const encodedPath = encodePath(path); - if (supabaseUrl) { - return `${supabaseUrl}/storage/v1/object/public/${bucket}/${encodedPath}`; + if (storageBaseUrl) { + return `${storageBaseUrl}/storage/v1/object/public/${bucket}/${encodedPath}`; } // Fallback for SSR or missing env var @@ -192,9 +192,13 @@ export function resolveThemeConfigImageUrls(themeConfig: T): T { // ============================================================================ /** - * Get the Supabase URL from environment. - * NEXT_PUBLIC_ env vars are available on both client and server. + * Get the public storage base URL from environment. + * Falls back to the Supabase URL when a dedicated storage URL is not configured. */ export function getSupabaseUrl(): string | null { - return process.env.NEXT_PUBLIC_SUPABASE_URL ?? null; + return ( + process.env.NEXT_PUBLIC_STORAGE_URL ?? + process.env.NEXT_PUBLIC_SUPABASE_URL ?? + null + ); } diff --git a/apps/storage/.gitignore b/apps/storage/.gitignore new file mode 100644 index 00000000..e985853e --- /dev/null +++ b/apps/storage/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/packages/db/src/utils/storage-url.ts b/packages/db/src/utils/storage-url.ts index a757f352..bccf1720 100644 --- a/packages/db/src/utils/storage-url.ts +++ b/packages/db/src/utils/storage-url.ts @@ -13,6 +13,7 @@ * Bucket name for product images. */ export const PRODUCTS_BUCKET = "products"; +const STORAGE_PUBLIC_PREFIX = "/storage/v1/object/public/"; // ============================================================================= // URL BUILDING @@ -28,6 +29,46 @@ function encodePath(path: string): string { .join("/"); } +/** + * Escape regex metacharacters for safe dynamic patterns. + */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Decode URI path segments safely. + */ +function decodePath(path: string): string { + return path + .split("/") + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join("/"); +} + +/** + * Extract a storage object path from a full public storage URL. + */ +function extractStoragePathFromPublicUrl( + value: string, + bucket: string, +): string | null { + const escapedBucket = escapeRegExp(bucket); + const pattern = new RegExp( + `${STORAGE_PUBLIC_PREFIX}${escapedBucket}/(.+?)(?:[?#].*)?$`, + "i", + ); + const match = value.match(pattern); + if (!match?.[1]) return null; + return decodePath(match[1]); +} + /** * Build a public URL for a product image. * @@ -41,15 +82,27 @@ export function buildProductImageUrl( ): string | null { if (!imagePath) return null; - // If already a full URL, return as-is - if (isFullUrl(imagePath)) return imagePath; + // Normalize legacy full public storage URLs back to a storage path first. + const extractedStoragePath = extractStoragePathFromPublicUrl( + imagePath, + PRODUCTS_BUCKET, + ); + const normalizedPath = extractedStoragePath ?? imagePath; + + // Preserve external full URLs as-is. + if (isFullUrl(normalizedPath)) return normalizedPath; // If no storage base URL available, return the path as-is // (fallback for backward compatibility) - if (!storageBaseUrl) return imagePath; + if (!storageBaseUrl) { + return extractedStoragePath ? imagePath : normalizedPath; + } - const encodedPath = encodePath(imagePath); - return `${storageBaseUrl}/storage/v1/object/public/${PRODUCTS_BUCKET}/${encodedPath}`; + const normalizedStorageBaseUrl = storageBaseUrl.endsWith("/") + ? storageBaseUrl.slice(0, -1) + : storageBaseUrl; + const encodedPath = encodePath(normalizedPath); + return `${normalizedStorageBaseUrl}/storage/v1/object/public/${PRODUCTS_BUCKET}/${encodedPath}`; } /** @@ -65,8 +118,12 @@ export function isFullUrl(value: string | null | undefined): boolean { * Works in both Node.js and edge contexts. */ export function getSupabaseUrlFromEnv(): string | null { - // Try common environment variable names + // Prefer dedicated storage URL overrides, then fall back to Supabase URL. return ( - process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? null + process.env.SUPABASE_STORAGE_URL ?? + process.env.NEXT_PUBLIC_STORAGE_URL ?? + process.env.SUPABASE_URL ?? + process.env.NEXT_PUBLIC_SUPABASE_URL ?? + null ); } diff --git a/packages/jobs/src/lib/excel.ts b/packages/jobs/src/lib/excel.ts index 09abbf02..bb6b1ae2 100644 --- a/packages/jobs/src/lib/excel.ts +++ b/packages/jobs/src/lib/excel.ts @@ -364,6 +364,72 @@ export function joinSemicolon(arr: string[] | null | undefined): string { return arr.join("; "); } +const PRODUCTS_BUCKET = "products"; +const STORAGE_PUBLIC_PREFIX = "/storage/v1/object/public/"; + +/** + * Escape regex metacharacters for dynamic path extraction. + */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Decode URI path segments safely. + */ +function decodeStoragePath(pathValue: string): string { + return pathValue + .split("/") + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join("/"); +} + +/** + * Encode a storage path for inclusion in a URL. + */ +function encodeStoragePath(pathValue: string): string { + return pathValue + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +/** + * Extract the object path from a known storage public URL. + */ +function extractStoragePathFromPublicUrl( + value: string, + bucket: string, +): string | null { + const escapedBucket = escapeRegExp(bucket); + const pattern = new RegExp( + `${STORAGE_PUBLIC_PREFIX}${escapedBucket}/(.+?)(?:[?#].*)?$`, + "i", + ); + const match = value.match(pattern); + if (!match?.[1]) return null; + return decodeStoragePath(match[1]); +} + +/** + * Resolve the preferred public storage base URL from environment. + */ +function getPublicStorageBaseUrl(): string | null { + return ( + process.env.SUPABASE_STORAGE_URL ?? + process.env.NEXT_PUBLIC_STORAGE_URL ?? + process.env.SUPABASE_URL ?? + process.env.NEXT_PUBLIC_SUPABASE_URL ?? + null + ); +} + export function formatMaterials( materials: | Array<{ name: string; percentage: number | null }> @@ -381,15 +447,29 @@ export function formatMaterials( export function buildImageUrl(imagePath: string | null | undefined): string { if (!imagePath) return ""; - if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { - return imagePath; + + const extractedPath = extractStoragePathFromPublicUrl( + imagePath, + PRODUCTS_BUCKET, + ); + const normalizedPath = extractedPath ?? imagePath; + if ( + normalizedPath.startsWith("http://") || + normalizedPath.startsWith("https://") + ) { + return normalizedPath; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - if (!supabaseUrl) return imagePath; - const baseUrl = supabaseUrl.endsWith("/") - ? supabaseUrl.slice(0, -1) - : supabaseUrl; - return `${baseUrl}/storage/v1/object/public/products/${imagePath}`; + + const storageBaseUrl = getPublicStorageBaseUrl(); + if (!storageBaseUrl) { + return extractedPath ? imagePath : normalizedPath; + } + + const baseUrl = storageBaseUrl.endsWith("/") + ? storageBaseUrl.slice(0, -1) + : storageBaseUrl; + const encodedPath = encodeStoragePath(normalizedPath); + return `${baseUrl}/storage/v1/object/public/${PRODUCTS_BUCKET}/${encodedPath}`; } export function getAttributeByIndex( diff --git a/packages/jobs/src/trigger/bulk/export-qr-codes.ts b/packages/jobs/src/trigger/bulk/export-qr-codes.ts index 6f4b71eb..295a85cc 100644 --- a/packages/jobs/src/trigger/bulk/export-qr-codes.ts +++ b/packages/jobs/src/trigger/bulk/export-qr-codes.ts @@ -80,6 +80,43 @@ const QR_GENERATION_MAX_THREADS = 6; const DOWNLOAD_EXPIRY_DAYS = 7; const EMAIL_FROM = "Avelero "; +/** + * Resolve the preferred storage base URL for public assets. + */ +function getStoragePublicBaseUrl(): string | null { + return ( + process.env.SUPABASE_STORAGE_URL ?? + process.env.NEXT_PUBLIC_STORAGE_URL ?? + null + ); +} + +/** + * Rewrite Supabase public storage URLs to the configured storage domain. + */ +function remapStoragePublicUrl(url: string): string { + const storageBaseUrl = getStoragePublicBaseUrl(); + if (!storageBaseUrl) return url; + + try { + const parsedUrl = new URL(url); + if (!parsedUrl.pathname.startsWith("/storage/v1/object/public/")) { + return url; + } + + const parsedStorageBaseUrl = new URL(storageBaseUrl); + parsedUrl.protocol = parsedStorageBaseUrl.protocol; + parsedUrl.hostname = parsedStorageBaseUrl.hostname; + parsedUrl.port = parsedStorageBaseUrl.port; + return parsedUrl.toString(); + } catch { + return url; + } +} + +/** + * Create the service-role Supabase client used by this job. + */ function createSupabaseServiceClient() { const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; @@ -473,7 +510,7 @@ export const exportQrCodes = task({ variantUpid: row.variantUpid, barcode: row.barcode, gs1DigitalLinkUrl, - qrPngUrl: publicData.publicUrl, + qrPngUrl: remapStoragePublicUrl(publicData.publicUrl), }; } catch (error) { failedVariants.push({ diff --git a/packages/jobs/trigger.config.ts b/packages/jobs/trigger.config.ts index a6b2caba..d3218286 100644 --- a/packages/jobs/trigger.config.ts +++ b/packages/jobs/trigger.config.ts @@ -1,3 +1,6 @@ +/** + * Trigger.dev build/runtime configuration for background jobs. + */ import { additionalFiles, syncEnvVars, @@ -73,6 +76,20 @@ export const config: TriggerConfig = { }); } + if (process.env.SUPABASE_STORAGE_URL) { + envVars.push({ + name: "SUPABASE_STORAGE_URL", + value: process.env.SUPABASE_STORAGE_URL, + }); + } + + if (process.env.NEXT_PUBLIC_STORAGE_URL) { + envVars.push({ + name: "NEXT_PUBLIC_STORAGE_URL", + value: process.env.NEXT_PUBLIC_STORAGE_URL, + }); + } + if (process.env.SUPABASE_SERVICE_KEY) { envVars.push({ name: "SUPABASE_SERVICE_KEY", @@ -106,6 +123,12 @@ export const config: TriggerConfig = { console.log( `[syncEnvVars] NEXT_PUBLIC_SUPABASE_URL: ${process.env.NEXT_PUBLIC_SUPABASE_URL ? "SET" : "NOT SET"}`, ); + console.log( + `[syncEnvVars] SUPABASE_STORAGE_URL: ${process.env.SUPABASE_STORAGE_URL ? "SET" : "NOT SET"}`, + ); + console.log( + `[syncEnvVars] NEXT_PUBLIC_STORAGE_URL: ${process.env.NEXT_PUBLIC_STORAGE_URL ? "SET" : "NOT SET"}`, + ); console.log( `[syncEnvVars] SUPABASE_SERVICE_KEY: ${process.env.SUPABASE_SERVICE_KEY ? "SET" : "NOT SET"}`, ); diff --git a/packages/supabase/src/utils/storage.ts b/packages/supabase/src/utils/storage.ts index cbaa6f7e..2233dbf4 100644 --- a/packages/supabase/src/utils/storage.ts +++ b/packages/supabase/src/utils/storage.ts @@ -11,6 +11,7 @@ import type { Database } from "../types"; export type StorageClient = Pick, "storage">; export const EMPTY_FOLDER_PLACEHOLDER_FILE_NAME = ".emptyFolderPlaceholder"; +const STORAGE_PUBLIC_PATH_PREFIX = "/storage/v1/object/public/"; // ============================================================================ // Upload / Remove / Download @@ -90,7 +91,43 @@ export function getPublicUrl( ): string | null { if (!path) return null; const { data } = client.storage.from(bucket).getPublicUrl(path); - return data?.publicUrl ?? null; + return remapStoragePublicUrl(data?.publicUrl ?? null); +} + +/** + * Resolve the preferred public base URL for storage objects. + */ +function getStoragePublicBaseUrl(): string | null { + return ( + process.env.SUPABASE_STORAGE_URL ?? + process.env.NEXT_PUBLIC_STORAGE_URL ?? + null + ); +} + +/** + * Rewrite Supabase public object URLs to the configured storage domain. + */ +function remapStoragePublicUrl(url: string | null): string | null { + if (!url) return null; + + const storageBaseUrl = getStoragePublicBaseUrl(); + if (!storageBaseUrl) return url; + + try { + const parsedUrl = new URL(url); + if (!parsedUrl.pathname.startsWith(STORAGE_PUBLIC_PATH_PREFIX)) { + return url; + } + + const parsedStorageBaseUrl = new URL(storageBaseUrl); + parsedUrl.protocol = parsedStorageBaseUrl.protocol; + parsedUrl.hostname = parsedStorageBaseUrl.hostname; + parsedUrl.port = parsedStorageBaseUrl.port; + return parsedUrl.toString(); + } catch { + return url; + } } /** From 3a25aa5bb57fa8cd5ba05d777b17d3c476334303 Mon Sep 17 00:00:00 2001 From: Raf Mevis Date: Thu, 5 Mar 2026 19:33:52 +0100 Subject: [PATCH 4/7] Codereview issue fixes. --- .../app/src/components/select/font-select.tsx | 3 +- .../panel/views/typography-editor.tsx | 24 +++++++++--- apps/app/src/hooks/use-passport-form.ts | 30 +++++++------- apps/app/src/hooks/use-variant-form.ts | 39 +++++++------------ apps/app/src/lib/percentage-utils.ts | 27 +++++++++++++ .../src/components/layout/product-image.tsx | 6 +-- .../components/materials/materials-frame.tsx | 11 +----- .../components/product/product-details.tsx | 11 +----- .../dpp-components/src/lib/google-fonts.ts | 14 ++++++- packages/dpp-components/src/lib/url-utils.ts | 13 +++++++ 10 files changed, 103 insertions(+), 75 deletions(-) create mode 100644 apps/app/src/lib/percentage-utils.ts create mode 100644 packages/dpp-components/src/lib/url-utils.ts diff --git a/apps/app/src/components/select/font-select.tsx b/apps/app/src/components/select/font-select.tsx index fb598c88..9b43ac92 100644 --- a/apps/app/src/components/select/font-select.tsx +++ b/apps/app/src/components/select/font-select.tsx @@ -130,10 +130,9 @@ export function FontSelect({ const previousSources = customFontSourcesSnapshot.current; const addedFonts = customFonts.filter((font) => !previousSources.has(font.src)); customFontSourcesSnapshot.current = new Set(customFonts.map((font) => font.src)); + pendingCustomFontAutoSelect.current = false; if (addedFonts.length === 0) return; - - pendingCustomFontAutoSelect.current = false; const latestAddedFont = addedFonts[addedFonts.length - 1]; if (!latestAddedFont) return; diff --git a/apps/app/src/components/theme-editor/panel/views/typography-editor.tsx b/apps/app/src/components/theme-editor/panel/views/typography-editor.tsx index 062ddfd0..1a5bea5c 100644 --- a/apps/app/src/components/theme-editor/panel/views/typography-editor.tsx +++ b/apps/app/src/components/theme-editor/panel/views/typography-editor.tsx @@ -114,6 +114,12 @@ function toWeightOption(weight: number): { value: string; label: string } { return { value: String(weight), label: `${bucketLabel} (${weight})` }; } +function getFallbackAxisWeight(start: number, end: number): number { + // Clamp regular weight into axis range to provide a valid single fallback option. + const clampedWeight = Math.min(Math.max(400, start), end); + return Math.round(clampedWeight); +} + function collectWeightsFromCustomFont(font: CustomFont): number[] { // Expand custom font metadata into concrete selectable weight values. if (typeof font.fontWeight === "number") { @@ -176,13 +182,19 @@ function getAvailableWeightOptions( if (googleFont.isVariable) { const weightAxis = googleFont.axes.find((axis) => axis.tag === "wght"); - if (weightAxis) { - for (const weight of FONT_WEIGHT_VALUES) { - if (weight >= weightAxis.start && weight <= weightAxis.end) { - googleWeights.add(weight); - } + if (!weightAxis) { + return [toWeightOption(400)]; + } + + for (const weight of FONT_WEIGHT_VALUES) { + if (weight >= weightAxis.start && weight <= weightAxis.end) { + googleWeights.add(weight); } } + + if (googleWeights.size === 0) { + googleWeights.add(getFallbackAxisWeight(weightAxis.start, weightAxis.end)); + } } else if (googleFont.variants?.length) { for (const variant of googleFont.variants) { const variantWeight = parseVariantWeight(variant); @@ -197,7 +209,7 @@ function getAvailableWeightOptions( const sortedGoogleWeights = Array.from(googleWeights).sort((a, b) => a - b); return sortedGoogleWeights.length > 0 ? sortedGoogleWeights.map(toWeightOption) - : FONT_WEIGHT_OPTIONS; + : [toWeightOption(400)]; } // ============================================================================ diff --git a/apps/app/src/hooks/use-passport-form.ts b/apps/app/src/hooks/use-passport-form.ts index d8398e7c..78b2f2e0 100644 --- a/apps/app/src/hooks/use-passport-form.ts +++ b/apps/app/src/hooks/use-passport-form.ts @@ -1,3 +1,8 @@ +/** + * usePassportForm + * + * Form state management hook for creating and editing passport-level product data. + */ import type { ExpandedVariantMappings, VariantDimension, @@ -12,6 +17,12 @@ import { validateForm, } from "@/hooks/use-form-validation"; import { useImageUpload } from "@/hooks/use-upload"; +import { + MAX_PERCENTAGE_UNITS, + formatPercentageFromUnits, + isPercentageWithinBounds, + toPercentageUnits, +} from "@/lib/percentage-utils"; import { useTRPC } from "@/trpc/client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "@v1/ui/sonner"; @@ -202,20 +213,6 @@ type PassportFormValidationFields = Pick< | "weightGrams" >; -const PERCENTAGE_SCALE = 100; -const MAX_PERCENTAGE_UNITS = 100 * PERCENTAGE_SCALE; - -function toPercentageUnits(value: number): number { - // Convert percentages to integer hundredths to avoid floating-point drift. - if (!Number.isFinite(value)) return 0; - return Math.round((value + Number.EPSILON) * PERCENTAGE_SCALE); -} - -function formatPercentageFromUnits(units: number): string { - // Format integer hundredths back to a clean percentage string. - return (units / PERCENTAGE_SCALE).toFixed(2).replace(/\.?0+$/, ""); -} - const passportFormSchema: ValidationSchema = { name: [ rules.required("Name is required"), @@ -254,11 +251,10 @@ const passportFormSchema: ValidationSchema = { if (!Number.isFinite(material.percentage)) { return "Material percentages must be valid numbers"; } - const percentageUnits = toPercentageUnits(material.percentage); - if (percentageUnits < 0 || percentageUnits > MAX_PERCENTAGE_UNITS) { + if (!isPercentageWithinBounds(material.percentage)) { return "Material percentages must be between 0 and 100"; } - totalUnits += percentageUnits; + totalUnits += toPercentageUnits(material.percentage); } if (totalUnits > MAX_PERCENTAGE_UNITS) { diff --git a/apps/app/src/hooks/use-variant-form.ts b/apps/app/src/hooks/use-variant-form.ts index 3232ce8c..dd94c85f 100644 --- a/apps/app/src/hooks/use-variant-form.ts +++ b/apps/app/src/hooks/use-variant-form.ts @@ -7,6 +7,12 @@ import { useFormState } from "@/hooks/use-form-state"; import { useImageUpload } from "@/hooks/use-upload"; +import { + MAX_PERCENTAGE_UNITS, + formatPercentageFromUnits, + isPercentageWithinBounds, + toPercentageUnits, +} from "@/lib/percentage-utils"; import { useTRPC } from "@/trpc/client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "@v1/ui/sonner"; @@ -101,20 +107,6 @@ const initialFormValues: VariantFormValues = { journeySteps: [], }; -const PERCENTAGE_SCALE = 100; -const MAX_PERCENTAGE_UNITS = 100 * PERCENTAGE_SCALE; - -function toPercentageUnits(value: number): number { - // Convert percentages to integer hundredths to avoid floating-point drift. - if (!Number.isFinite(value)) return 0; - return Math.round((value + Number.EPSILON) * PERCENTAGE_SCALE); -} - -function formatPercentageFromUnits(units: number): string { - // Format integer hundredths back to a clean percentage string. - return (units / PERCENTAGE_SCALE).toFixed(2).replace(/\.?0+$/, ""); -} - // ============================================================================ // Hook // ============================================================================ @@ -271,18 +263,15 @@ export function useVariantForm(options: UseVariantFormOptions) { // Materials percentage validation let totalPercentageUnits = 0; for (const material of state.materials) { - if (material.percentage) { - if (!Number.isFinite(material.percentage)) { - errors.materials = "Material percentages must be valid numbers"; - break; - } - const percentageUnits = toPercentageUnits(material.percentage); - if (percentageUnits < 0) { - errors.materials = "Material percentages must be positive numbers"; - break; - } - totalPercentageUnits += percentageUnits; + if (!Number.isFinite(material.percentage)) { + errors.materials = "Material percentages must be valid numbers"; + break; + } + if (!isPercentageWithinBounds(material.percentage)) { + errors.materials = "Material percentages must be between 0 and 100"; + break; } + totalPercentageUnits += toPercentageUnits(material.percentage); } if (totalPercentageUnits > MAX_PERCENTAGE_UNITS) { errors.materials = `Material percentages sum to ${formatPercentageFromUnits(totalPercentageUnits)}%, but cannot exceed 100%`; diff --git a/apps/app/src/lib/percentage-utils.ts b/apps/app/src/lib/percentage-utils.ts new file mode 100644 index 00000000..40c9b4b8 --- /dev/null +++ b/apps/app/src/lib/percentage-utils.ts @@ -0,0 +1,27 @@ +/** + * Shared percentage conversion and validation utilities for form hooks. + */ + +export const PERCENTAGE_SCALE = 100; +export const MAX_PERCENTAGE_UNITS = 100 * PERCENTAGE_SCALE; + +function clampToInteger(value: number): number { + // Round percentage-unit values to stable integers for deterministic totals. + return Math.round(value); +} + +export function toPercentageUnits(value: number): number { + // Convert percentages to integer hundredths to avoid floating-point drift. + if (!Number.isFinite(value)) return 0; + return clampToInteger((value + Number.EPSILON) * PERCENTAGE_SCALE); +} + +export function formatPercentageFromUnits(units: number): string { + // Format integer hundredths back to a clean percentage string. + return (units / PERCENTAGE_SCALE).toFixed(2).replace(/\.?0+$/, ""); +} + +export function isPercentageWithinBounds(value: number): boolean { + // Validate raw percentage values before any rounding occurs. + return Number.isFinite(value) && value >= 0 && value <= 100; +} diff --git a/packages/dpp-components/src/components/layout/product-image.tsx b/packages/dpp-components/src/components/layout/product-image.tsx index 83857d71..0bbad3d9 100644 --- a/packages/dpp-components/src/components/layout/product-image.tsx +++ b/packages/dpp-components/src/components/layout/product-image.tsx @@ -9,7 +9,7 @@ interface Props { } export function ProductImage({ image, alt }: Props) { - // Render the product image at full width while preserving its native aspect ratio. + // Reserve intrinsic dimensions to reduce layout shift while keeping responsive scaling. // Next.js blocks image optimization for private IPs (security feature) // Use unoptimized for local development URLs const isLocalDev = @@ -21,8 +21,8 @@ export function ProductImage({ image, alt }: Props) { {alt} axis.tag === "wght"); if (!weightAxis) { - return PRESET_WEIGHTS; + return [400]; } - return PRESET_WEIGHTS.filter( + const variableWeights = PRESET_WEIGHTS.filter( (weight) => weight >= weightAxis.start && weight <= weightAxis.end, ); + if (variableWeights.length > 0) { + return variableWeights; + } + return [getFallbackAxisWeight(weightAxis.start, weightAxis.end)]; } const staticWeights = Array.from( diff --git a/packages/dpp-components/src/lib/url-utils.ts b/packages/dpp-components/src/lib/url-utils.ts new file mode 100644 index 00000000..ae616716 --- /dev/null +++ b/packages/dpp-components/src/lib/url-utils.ts @@ -0,0 +1,13 @@ +/** + * URL normalization helpers for external links in DPP components. + */ + +const EXTERNAL_PROTOCOL_REGEX = /^https?:\/\//i; + +export function toExternalHref(url?: string): string | undefined { + // Normalize optional URLs so plain domains remain clickable external links. + const trimmed = url?.trim(); + if (!trimmed) return undefined; + if (EXTERNAL_PROTOCOL_REGEX.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} From 4a01bd9f042bdd7d66339713c6bacc22c128b162 Mon Sep 17 00:00:00 2001 From: Raf Mevis Date: Thu, 5 Mar 2026 19:42:21 +0100 Subject: [PATCH 5/7] Fix variant font review issues --- apps/storage/.gitignore | 1 - .../jobs/src/trigger/bulk/export-qr-codes.ts | 48 ++++--------------- 2 files changed, 10 insertions(+), 39 deletions(-) delete mode 100644 apps/storage/.gitignore diff --git a/apps/storage/.gitignore b/apps/storage/.gitignore deleted file mode 100644 index e985853e..00000000 --- a/apps/storage/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.vercel diff --git a/packages/jobs/src/trigger/bulk/export-qr-codes.ts b/packages/jobs/src/trigger/bulk/export-qr-codes.ts index 295a85cc..fa426fce 100644 --- a/packages/jobs/src/trigger/bulk/export-qr-codes.ts +++ b/packages/jobs/src/trigger/bulk/export-qr-codes.ts @@ -30,6 +30,7 @@ import { resolveQrExportProductIds, } from "@v1/db/queries/products"; import QrExportReadyEmail from "@v1/email/emails/qr-export-ready"; +import { getPublicUrl } from "@v1/supabase/storage"; import { type GenerateQrPngOptions, type QrExportCsvRow, @@ -80,40 +81,6 @@ const QR_GENERATION_MAX_THREADS = 6; const DOWNLOAD_EXPIRY_DAYS = 7; const EMAIL_FROM = "Avelero "; -/** - * Resolve the preferred storage base URL for public assets. - */ -function getStoragePublicBaseUrl(): string | null { - return ( - process.env.SUPABASE_STORAGE_URL ?? - process.env.NEXT_PUBLIC_STORAGE_URL ?? - null - ); -} - -/** - * Rewrite Supabase public storage URLs to the configured storage domain. - */ -function remapStoragePublicUrl(url: string): string { - const storageBaseUrl = getStoragePublicBaseUrl(); - if (!storageBaseUrl) return url; - - try { - const parsedUrl = new URL(url); - if (!parsedUrl.pathname.startsWith("/storage/v1/object/public/")) { - return url; - } - - const parsedStorageBaseUrl = new URL(storageBaseUrl); - parsedUrl.protocol = parsedStorageBaseUrl.protocol; - parsedUrl.hostname = parsedStorageBaseUrl.hostname; - parsedUrl.port = parsedStorageBaseUrl.port; - return parsedUrl.toString(); - } catch { - return url; - } -} - /** * Create the service-role Supabase client used by this job. */ @@ -501,16 +468,21 @@ export const exportQrCodes = task({ cacheHitCount += 1; } - const { data: publicData } = supabase.storage - .from(QR_IMAGES_BUCKET) - .getPublicUrl(qrPngPath); + const qrPngUrl = getPublicUrl( + supabase, + QR_IMAGES_BUCKET, + qrPngPath, + ); + if (!qrPngUrl) { + throw new Error("Failed to resolve public URL for QR PNG"); + } csvRowsByIndex[index] = { productTitle: row.productTitle, variantUpid: row.variantUpid, barcode: row.barcode, gs1DigitalLinkUrl, - qrPngUrl: remapStoragePublicUrl(publicData.publicUrl), + qrPngUrl, }; } catch (error) { failedVariants.push({ From 8312da88fa97b848d587e9ac0559d0fc5afadac9 Mon Sep 17 00:00:00 2001 From: Raf Mevis Date: Thu, 5 Mar 2026 22:12:13 +0100 Subject: [PATCH 6/7] Fixed codereview issues & configures storage.avelero.com. --- apps/api/src/lib/dpp-revalidation.ts | 53 +++++ apps/api/src/trpc/routers/catalog/index.ts | 152 ++++++++++-- apps/api/src/trpc/routers/products/index.ts | 12 +- apps/api/src/trpc/routers/products/publish.ts | 22 ++ apps/dpp/src/app/api/revalidate/route.ts | 10 +- apps/storage/vercel.json | 18 -- .../src/queries/products/catalog-fan-out.ts | 225 ++++++++++++++++++ packages/db/src/queries/products/index.ts | 1 + packages/jobs/package.json | 3 +- packages/jobs/src/lib/dpp-revalidation.ts | 47 ++++ packages/jobs/src/trigger/catalog/fan-out.ts | 167 +++++++++++++ packages/jobs/src/trigger/catalog/index.ts | 2 + packages/jobs/src/trigger/index.ts | 3 + 13 files changed, 677 insertions(+), 38 deletions(-) delete mode 100644 apps/storage/vercel.json create mode 100644 packages/db/src/queries/products/catalog-fan-out.ts create mode 100644 packages/jobs/src/trigger/catalog/fan-out.ts create mode 100644 packages/jobs/src/trigger/catalog/index.ts diff --git a/apps/api/src/lib/dpp-revalidation.ts b/apps/api/src/lib/dpp-revalidation.ts index 10006005..731ba34e 100644 --- a/apps/api/src/lib/dpp-revalidation.ts +++ b/apps/api/src/lib/dpp-revalidation.ts @@ -4,6 +4,12 @@ * Provides functions to invalidate cached DPP pages when product/brand data changes. * Uses on-demand revalidation via the DPP app's /api/revalidate endpoint. * + * Cache tag naming conventions: + * - `dpp-passport-{upid}` - Per-passport invalidation (primary, matches DPP fetch tags) + * - `dpp-barcode-{brandId}-{barcode}` - Per-barcode invalidation (matches DPP fetch tags) + * - `dpp-product-{productHandle}` - Per-product invalidation (legacy) + * - `dpp-brand-{brandSlug}` - Brand-wide invalidation (bulk fallback) + * * Environment variables required: * - DPP_URL or NEXT_PUBLIC_DPP_URL: Base URL of the DPP app * - DPP_REVALIDATION_SECRET: Shared secret for authentication @@ -116,3 +122,50 @@ export function revalidateBrand(brandSlug: string): Promise { if (!brandSlug) return Promise.resolve(); return revalidateDppCache([`dpp-brand-${brandSlug}`]); } + +/** + * Revalidate DPP cache for a list of published passports by UPID. + * + * These tags match the `dpp-passport-{upid}` tags applied at fetch time in + * apps/dpp/src/lib/api.ts fetchPassportDpp(). Call this after publishing. + * + * Tags are sent in chunks to avoid hitting request size limits. + * + * @param upids - Array of UPIDs to invalidate + */ +export async function revalidatePassports(upids: string[]): Promise { + const filtered = upids.filter(Boolean); + if (filtered.length === 0) return; + const CHUNK_SIZE = 100; + for (let i = 0; i < filtered.length; i += CHUNK_SIZE) { + const chunk = filtered.slice(i, i + CHUNK_SIZE); + await revalidateDppCache(chunk.map((upid) => `dpp-passport-${upid}`)); + } +} + +/** + * Revalidate DPP cache for a list of barcodes within a brand. + * + * These tags match the `dpp-barcode-{brandId}-{barcode}` tags applied at + * fetch time in apps/dpp/src/lib/api.ts fetchPassportByBarcode(). Call this + * after publishing variants that have barcodes. + * + * Tags are sent in chunks to avoid hitting request size limits. + * + * @param brandId - The brand UUID + * @param barcodes - Array of barcodes to invalidate + */ +export async function revalidateBarcodes( + brandId: string, + barcodes: string[], +): Promise { + const filtered = barcodes.filter(Boolean); + if (!brandId || filtered.length === 0) return; + const CHUNK_SIZE = 100; + for (let i = 0; i < filtered.length; i += CHUNK_SIZE) { + const chunk = filtered.slice(i, i + CHUNK_SIZE); + await revalidateDppCache( + chunk.map((barcode) => `dpp-barcode-${brandId}-${barcode}`), + ); + } +} diff --git a/apps/api/src/trpc/routers/catalog/index.ts b/apps/api/src/trpc/routers/catalog/index.ts index 7ed11420..3ed2175f 100644 --- a/apps/api/src/trpc/routers/catalog/index.ts +++ b/apps/api/src/trpc/routers/catalog/index.ts @@ -1,4 +1,5 @@ import type { Database } from "@v1/db/client"; +import { tasks } from "@trigger.dev/sdk/v3"; /** * Catalog router implementation. * @@ -279,6 +280,36 @@ function createDeleteProcedure( }); } +/** + * Enqueue a catalog fan-out job for the given entity. + * + * Fire-and-forget: trigger failures are logged but do not propagate, + * since fan-out is a background concern and should never fail a catalog write. + * + * Uses a 45-second delay so that rapid consecutive edits within the same window + * arrive at the task with the latest data. Multiple triggers within the window + * will each schedule a run, but publish is content-hash-deduplicated so only + * genuine content changes produce new versions (redundant runs are no-ops). + */ +function enqueueCatalogFanOut( + brandId: string, + entityType: "manufacturer" | "material" | "certification" | "operator", + entityId: string, +): void { + tasks + .trigger( + "catalog-fan-out", + { brandId, entityType, entityId }, + { delay: "45s" }, + ) + .catch((err) => { + console.error( + `[CatalogFanOut] Failed to enqueue fan-out for ${entityType} ${entityId}:`, + err, + ); + }); +} + /** * Factory function creating a complete CRUD router for catalog resources. * @@ -294,6 +325,7 @@ function createDeleteProcedure( * @param schemas - Zod schemas for each operation * @param operations - Database query functions for each operation * @param transformInput - Optional function to transform snake_case schema to camelCase DB input + * @param fanOutEntityType - If set, triggers catalog fan-out after successful mutations * @returns tRPC router with list/create/update/delete endpoints * * @example @@ -327,6 +359,7 @@ function createCatalogResourceRouter( delete: (db: Database, brandId: string, id: string) => Promise; }, transformInput?: (input: any) => any, + fanOutEntityType?: "manufacturer" | "material" | "certification" | "operator", ) { return createTRPCRouter({ list: createListProcedure( @@ -335,23 +368,104 @@ function createCatalogResourceRouter( resourceName, transformInput, ), - create: createCreateProcedure( - schemas.create, - operations.create, - resourceName, - transformInput, - ), - update: createUpdateProcedure( - schemas.update, - operations.update, - resourceName, - transformInput, - ), - delete: createDeleteProcedure( - schemas.delete, - operations.delete, - resourceName, - ), + create: fanOutEntityType + ? brandWriteProcedure + .input(schemas.create) + .mutation(async ({ ctx, input }) => { + const brandCtx = ctx as BrandContext; + try { + const transformedInput = transformInput + ? transformInput(input) + : input; + const result = await operations.create( + brandCtx.db, + brandCtx.brandId, + transformedInput, + ); + const response = createEntityResponse(result); + enqueueCatalogFanOut( + brandCtx.brandId, + fanOutEntityType, + (result as any).id, + ); + return response; + } catch (error) { + throw wrapError(error, `Failed to create ${resourceName}`); + } + }) + : createCreateProcedure( + schemas.create, + operations.create, + resourceName, + transformInput, + ), + update: fanOutEntityType + ? brandWriteProcedure + .input(schemas.update) + .mutation(async ({ ctx, input }) => { + const brandCtx = ctx as BrandContext; + const typedInput = input as { id: string }; + try { + const transformedInput = transformInput + ? transformInput(typedInput) + : typedInput; + const result = await operations.update( + brandCtx.db, + brandCtx.brandId, + typedInput.id, + transformedInput, + ); + if (!result) { + throw notFound(resourceName, typedInput.id); + } + const response = createEntityResponse(result); + enqueueCatalogFanOut( + brandCtx.brandId, + fanOutEntityType, + typedInput.id, + ); + return response; + } catch (error) { + throw wrapError(error, `Failed to update ${resourceName}`); + } + }) + : createUpdateProcedure( + schemas.update, + operations.update, + resourceName, + transformInput, + ), + delete: fanOutEntityType + ? brandWriteProcedure + .input(schemas.delete) + .mutation(async ({ ctx, input }) => { + const brandCtx = ctx as BrandContext; + const typedInput = input as { id: string }; + try { + const result = await operations.delete( + brandCtx.db, + brandCtx.brandId, + typedInput.id, + ); + if (!result) { + throw notFound(resourceName, typedInput.id); + } + const response = createEntityResponse(result); + enqueueCatalogFanOut( + brandCtx.brandId, + fanOutEntityType, + typedInput.id, + ); + return response; + } catch (error) { + throw wrapError(error, `Failed to delete ${resourceName}`); + } + }) + : createDeleteProcedure( + schemas.delete, + operations.delete, + resourceName, + ), }); } @@ -573,6 +687,7 @@ export const catalogRouter = createTRPCRouter({ delete: deleteMaterial, }, transformMaterialInput, + "material", ), /** @@ -617,6 +732,7 @@ export const catalogRouter = createTRPCRouter({ delete: deleteOperator, }, transformOperatorInput, + "operator", ), /** @@ -640,6 +756,7 @@ export const catalogRouter = createTRPCRouter({ delete: deleteBrandManufacturer, }, transformManufacturerInput, + "manufacturer", ), /** @@ -662,6 +779,7 @@ export const catalogRouter = createTRPCRouter({ delete: deleteCertification, }, transformCertificationInput, + "certification", ), }); diff --git a/apps/api/src/trpc/routers/products/index.ts b/apps/api/src/trpc/routers/products/index.ts index 9f948f5c..a2087ee0 100644 --- a/apps/api/src/trpc/routers/products/index.ts +++ b/apps/api/src/trpc/routers/products/index.ts @@ -36,7 +36,10 @@ import { products, qrExportJobs, } from "@v1/db/schema"; -import { revalidateProduct } from "../../../lib/dpp-revalidation.js"; +import { + revalidatePassports, + revalidateProduct, +} from "../../../lib/dpp-revalidation.js"; import { generateProductHandle } from "../../../schemas/_shared/primitives.js"; import { productUnifiedGetSchema, @@ -483,6 +486,13 @@ export const productsRouter = createTRPCRouter({ "Publish failed after status change:", publishResult.error, ); + } else { + const upids = publishResult.variants + .map((v) => v.passport?.upid) + .filter((u): u is string => Boolean(u)); + if (upids.length > 0) { + revalidatePassports(upids).catch(() => {}); + } } } catch (err) { console.error( diff --git a/apps/api/src/trpc/routers/products/publish.ts b/apps/api/src/trpc/routers/products/publish.ts index 9e91bc72..835c11b1 100644 --- a/apps/api/src/trpc/routers/products/publish.ts +++ b/apps/api/src/trpc/routers/products/publish.ts @@ -17,6 +17,10 @@ import { publishVariant, } from "@v1/db/queries/products"; import { z } from "zod"; +import { + revalidateBarcodes, + revalidatePassports, +} from "../../../lib/dpp-revalidation.js"; import { badRequest, wrapError } from "../../../utils/errors.js"; import type { AuthenticatedTRPCContext } from "../../init.js"; import { @@ -76,6 +80,10 @@ export const publishRouter = createTRPCRouter({ throw badRequest(result.error ?? "Failed to publish variant"); } + if (result.passport?.upid) { + revalidatePassports([result.passport.upid]).catch(() => {}); + } + return { success: true, variantId: result.variantId, @@ -109,6 +117,13 @@ export const publishRouter = createTRPCRouter({ throw badRequest(result.error ?? "Failed to publish product"); } + const upids = result.variants + .map((v) => v.passport?.upid) + .filter((u): u is string => Boolean(u)); + if (upids.length > 0) { + revalidatePassports(upids).catch(() => {}); + } + return { success: true, productId: result.productId, @@ -142,6 +157,13 @@ export const publishRouter = createTRPCRouter({ throw badRequest("Failed to bulk publish products"); } + const upids = result.products + .flatMap((p) => p.variants.map((v) => v.passport?.upid)) + .filter((u): u is string => Boolean(u)); + if (upids.length > 0) { + revalidatePassports(upids).catch(() => {}); + } + return { success: result.success, totalProductsPublished: result.totalProductsPublished, diff --git a/apps/dpp/src/app/api/revalidate/route.ts b/apps/dpp/src/app/api/revalidate/route.ts index 13b06ff0..89be79b7 100644 --- a/apps/dpp/src/app/api/revalidate/route.ts +++ b/apps/dpp/src/app/api/revalidate/route.ts @@ -7,6 +7,8 @@ * Authentication: Uses a shared secret to prevent unauthorized revalidation. * * Cache tags supported: + * - `dpp-passport-{upid}` - Invalidate a specific passport's DPP page (primary) + * - `dpp-barcode-{brandId}-{barcode}` - Invalidate a barcode-based DPP page (primary) * - `dpp-product-{productHandle}` - Invalidate a specific product's DPP page * - `dpp-variant-{variantUpid}` - Invalidate a specific variant's DPP page * - `dpp-brand-{brandSlug}` - Invalidate all DPP pages for a brand @@ -44,7 +46,13 @@ export async function POST(request: NextRequest) { } // Validate tag format (only allow expected prefixes) - const validPrefixes = ["dpp-product-", "dpp-variant-", "dpp-brand-"]; + const validPrefixes = [ + "dpp-passport-", + "dpp-barcode-", + "dpp-product-", + "dpp-variant-", + "dpp-brand-", + ]; const invalidTags = tags.filter( (tag) => !validPrefixes.some((prefix) => tag.startsWith(prefix)), ); diff --git a/apps/storage/vercel.json b/apps/storage/vercel.json deleted file mode 100644 index af565d2f..00000000 --- a/apps/storage/vercel.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "rewrites": [ - { - "source": "/storage/v1/object/public/:path*", - "destination": "https://auth.avelero.com/storage/v1/object/public/:path*" - } - ], - "headers": [ - { - "source": "/storage/v1/object/public/:path*", - "headers": [ - { "key": "x-vercel-enable-rewrite-caching", "value": "1" } - ] - } - ] - } - \ No newline at end of file diff --git a/packages/db/src/queries/products/catalog-fan-out.ts b/packages/db/src/queries/products/catalog-fan-out.ts new file mode 100644 index 00000000..bb554c6b --- /dev/null +++ b/packages/db/src/queries/products/catalog-fan-out.ts @@ -0,0 +1,225 @@ +/** + * Catalog Fan-Out Resolvers. + * + * Resolves which published product IDs are affected by a change to a catalog + * entity (manufacturer, material, certification, operator). Used by the + * background catalog fan-out job to identify which passports need republishing. + * + * All resolvers return deduplicated product IDs for published products only. + */ + +import { and, eq, inArray } from "drizzle-orm"; +import type { Database } from "../../client"; +import { + brandMaterials, + productJourneySteps, + productMaterials, + productVariants, + products, + variantJourneySteps, + variantMaterials, +} from "../../schema"; + +/** + * Find published product IDs affected by a manufacturer change. + * + * Manufacturers link directly to products via products.manufacturer_id. + */ +export async function findPublishedProductIdsByManufacturer( + db: Database, + brandId: string, + manufacturerId: string, +): Promise { + const rows = await db + .select({ id: products.id }) + .from(products) + .where( + and( + eq(products.brandId, brandId), + eq(products.manufacturerId, manufacturerId), + eq(products.status, "published"), + ), + ); + + return rows.map((r) => r.id); +} + +/** + * Find published product IDs affected by a material change. + * + * A material can appear on a product via product_materials or on individual + * variants via variant_materials. We resolve both paths and deduplicate. + */ +export async function findPublishedProductIdsByMaterial( + db: Database, + brandId: string, + materialId: string, +): Promise { + const productIds = new Set(); + + // Path 1: product_materials → products + const viaProduct = await db + .select({ productId: productMaterials.productId }) + .from(productMaterials) + .innerJoin(products, eq(products.id, productMaterials.productId)) + .where( + and( + eq(products.brandId, brandId), + eq(productMaterials.brandMaterialId, materialId), + eq(products.status, "published"), + ), + ); + + for (const r of viaProduct) { + productIds.add(r.productId); + } + + // Path 2: variant_materials → product_variants → products + const viaVariant = await db + .select({ productId: productVariants.productId }) + .from(variantMaterials) + .innerJoin( + productVariants, + eq(productVariants.id, variantMaterials.variantId), + ) + .innerJoin(products, eq(products.id, productVariants.productId)) + .where( + and( + eq(products.brandId, brandId), + eq(variantMaterials.brandMaterialId, materialId), + eq(products.status, "published"), + ), + ); + + for (const r of viaVariant) { + productIds.add(r.productId); + } + + return Array.from(productIds); +} + +/** + * Find published product IDs affected by a certification change. + * + * Certifications link to materials via brand_materials.certification_id. + * We first resolve which materials reference this certification, then + * delegate to the material resolver for both product and variant paths. + */ +export async function findPublishedProductIdsByCertification( + db: Database, + brandId: string, + certificationId: string, +): Promise { + // Step 1: find materials that reference this certification + const affectedMaterials = await db + .select({ id: brandMaterials.id }) + .from(brandMaterials) + .where( + and( + eq(brandMaterials.brandId, brandId), + eq(brandMaterials.certificationId, certificationId), + ), + ); + + if (affectedMaterials.length === 0) { + return []; + } + + const materialIds = affectedMaterials.map((m) => m.id); + const productIds = new Set(); + + // Path 1: product_materials → products + const viaProduct = await db + .select({ productId: productMaterials.productId }) + .from(productMaterials) + .innerJoin(products, eq(products.id, productMaterials.productId)) + .where( + and( + eq(products.brandId, brandId), + inArray(productMaterials.brandMaterialId, materialIds), + eq(products.status, "published"), + ), + ); + + for (const r of viaProduct) { + productIds.add(r.productId); + } + + // Path 2: variant_materials → product_variants → products + const viaVariant = await db + .select({ productId: productVariants.productId }) + .from(variantMaterials) + .innerJoin( + productVariants, + eq(productVariants.id, variantMaterials.variantId), + ) + .innerJoin(products, eq(products.id, productVariants.productId)) + .where( + and( + eq(products.brandId, brandId), + inArray(variantMaterials.brandMaterialId, materialIds), + eq(products.status, "published"), + ), + ); + + for (const r of viaVariant) { + productIds.add(r.productId); + } + + return Array.from(productIds); +} + +/** + * Find published product IDs affected by an operator change. + * + * Operators appear on journey steps, which can be at product level + * (product_journey_steps) or variant level (variant_journey_steps). + * We resolve both paths and deduplicate. + */ +export async function findPublishedProductIdsByOperator( + db: Database, + brandId: string, + operatorId: string, +): Promise { + const productIds = new Set(); + + // Path 1: product_journey_steps → products + const viaProduct = await db + .select({ productId: productJourneySteps.productId }) + .from(productJourneySteps) + .innerJoin(products, eq(products.id, productJourneySteps.productId)) + .where( + and( + eq(products.brandId, brandId), + eq(productJourneySteps.operatorId, operatorId), + eq(products.status, "published"), + ), + ); + + for (const r of viaProduct) { + productIds.add(r.productId); + } + + // Path 2: variant_journey_steps → product_variants → products + const viaVariant = await db + .select({ productId: productVariants.productId }) + .from(variantJourneySteps) + .innerJoin( + productVariants, + eq(productVariants.id, variantJourneySteps.variantId), + ) + .innerJoin(products, eq(products.id, productVariants.productId)) + .where( + and( + eq(products.brandId, brandId), + eq(variantJourneySteps.operatorId, operatorId), + eq(products.status, "published"), + ), + ); + + for (const r of viaVariant) { + productIds.add(r.productId); + } + + return Array.from(productIds); +} diff --git a/packages/db/src/queries/products/index.ts b/packages/db/src/queries/products/index.ts index ee7fb2b7..8d90a016 100644 --- a/packages/db/src/queries/products/index.ts +++ b/packages/db/src/queries/products/index.ts @@ -22,3 +22,4 @@ export * from "./publish"; export * from "./publish-batch"; export * from "./upid-generation"; export * from "./qr-export"; +export * from "./catalog-fan-out"; diff --git a/packages/jobs/package.json b/packages/jobs/package.json index c99d4450..de30c41d 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -42,6 +42,7 @@ "./trigger/delete-brand": "./src/trigger/delete-brand.ts", "./trigger/integrations": "./src/trigger/integrations/index.ts", "./trigger/integrations/sync": "./src/trigger/integrations/sync.ts", - "./trigger/integrations/scheduler": "./src/trigger/integrations/scheduler.ts" + "./trigger/integrations/scheduler": "./src/trigger/integrations/scheduler.ts", + "./trigger/catalog": "./src/trigger/catalog/index.ts" } } diff --git a/packages/jobs/src/lib/dpp-revalidation.ts b/packages/jobs/src/lib/dpp-revalidation.ts index 0e5af285..4fe04e19 100644 --- a/packages/jobs/src/lib/dpp-revalidation.ts +++ b/packages/jobs/src/lib/dpp-revalidation.ts @@ -96,3 +96,50 @@ export function revalidateBrand(brandSlug: string): Promise { if (!brandSlug) return Promise.resolve(); return revalidateDppCache([`dpp-brand-${brandSlug}`]); } + +/** + * Revalidate DPP cache for a list of published passports by UPID. + * + * These tags match the `dpp-passport-{upid}` tags applied at fetch time in + * apps/dpp/src/lib/api.ts fetchPassportDpp(). Call this after publishing. + * + * Tags are sent in chunks to avoid hitting request size limits. + * + * @param upids - Array of UPIDs to invalidate + */ +export async function revalidatePassports(upids: string[]): Promise { + const filtered = upids.filter(Boolean); + if (filtered.length === 0) return; + const CHUNK_SIZE = 100; + for (let i = 0; i < filtered.length; i += CHUNK_SIZE) { + const chunk = filtered.slice(i, i + CHUNK_SIZE); + await revalidateDppCache(chunk.map((upid) => `dpp-passport-${upid}`)); + } +} + +/** + * Revalidate DPP cache for a list of barcodes within a brand. + * + * These tags match the `dpp-barcode-{brandId}-{barcode}` tags applied at + * fetch time in apps/dpp/src/lib/api.ts fetchPassportByBarcode(). Call this + * after publishing variants that have barcodes. + * + * Tags are sent in chunks to avoid hitting request size limits. + * + * @param brandId - The brand UUID + * @param barcodes - Array of barcodes to invalidate + */ +export async function revalidateBarcodes( + brandId: string, + barcodes: string[], +): Promise { + const filtered = barcodes.filter(Boolean); + if (!brandId || filtered.length === 0) return; + const CHUNK_SIZE = 100; + for (let i = 0; i < filtered.length; i += CHUNK_SIZE) { + const chunk = filtered.slice(i, i + CHUNK_SIZE); + await revalidateDppCache( + chunk.map((barcode) => `dpp-barcode-${brandId}-${barcode}`), + ); + } +} diff --git a/packages/jobs/src/trigger/catalog/fan-out.ts b/packages/jobs/src/trigger/catalog/fan-out.ts new file mode 100644 index 00000000..40c1e4ba --- /dev/null +++ b/packages/jobs/src/trigger/catalog/fan-out.ts @@ -0,0 +1,167 @@ +/** + * Catalog Fan-Out Task + * + * Republishes DPP snapshots for all published products affected by a catalog + * entity change (manufacturer, material, certification, or operator). + * + * Each run is triggered with a 45-second delay from the API. Multiple triggers + * within the same window create multiple delayed runs, but publish is + * content-hash-deduplicated so only genuine content changes produce new versions + * — redundant runs are fast no-ops (versionsSkippedUnchanged will be high). + * + * Effective refresh latency: 45–90 seconds from the triggering edit. + */ + +import "../configure-trigger"; +import { logger, task } from "@trigger.dev/sdk/v3"; +import { serviceDb as db } from "@v1/db/client"; +import { eq, inArray } from "@v1/db/index"; +import { + findPublishedProductIdsByCertification, + findPublishedProductIdsByManufacturer, + findPublishedProductIdsByMaterial, + findPublishedProductIdsByOperator, + publishProductsSetBased, +} from "@v1/db/queries/products"; +import { productVariants, products } from "@v1/db/schema"; +import { and } from "drizzle-orm"; +import { + revalidateBarcodes, + revalidatePassports, +} from "../../lib/dpp-revalidation"; + +// ============================================================================= +// TYPES +// ============================================================================= + +export type CatalogEntityType = + | "manufacturer" + | "material" + | "certification" + | "operator"; + +export interface CatalogFanOutPayload { + brandId: string; + entityType: CatalogEntityType; + entityId: string; +} + +// ============================================================================= +// TASK +// ============================================================================= + +export const catalogFanOut = task({ + id: "catalog-fan-out", + // Concurrency limit of 1 per brand prevents fan-outs for the same brand from + // running simultaneously and stepping on each other's snapshot writes. + queue: { + name: "catalog-fan-out", + concurrencyLimit: 5, + }, + run: async (payload: CatalogFanOutPayload) => { + const { brandId, entityType, entityId } = payload; + + logger.info("Starting catalog fan-out", { brandId, entityType, entityId }); + + // Step 1: Resolve affected published product IDs. + let productIds: string[]; + switch (entityType) { + case "manufacturer": + productIds = await findPublishedProductIdsByManufacturer( + db, + brandId, + entityId, + ); + break; + case "material": + productIds = await findPublishedProductIdsByMaterial( + db, + brandId, + entityId, + ); + break; + case "certification": + productIds = await findPublishedProductIdsByCertification( + db, + brandId, + entityId, + ); + break; + case "operator": + productIds = await findPublishedProductIdsByOperator( + db, + brandId, + entityId, + ); + break; + default: + logger.warn("Unknown entity type, skipping", { entityType }); + return { skipped: true, reason: "unknown_entity_type" }; + } + + logger.info("Resolved affected products", { + entityType, + entityId, + productCount: productIds.length, + }); + + if (productIds.length === 0) { + logger.info("No published products affected, skipping publish"); + return { + productCount: 0, + versionsCreated: 0, + versionsSkippedUnchanged: 0, + }; + } + + // Step 2: Collect UPIDs and barcodes for cache revalidation after publish. + // We query before publishing so we have the identifiers even if publish + // produces no new versions (content-hash deduplication). + const variantRows = await db + .select({ + upid: productVariants.upid, + barcode: productVariants.barcode, + }) + .from(productVariants) + .innerJoin(products, eq(products.id, productVariants.productId)) + .where( + and( + eq(products.brandId, brandId), + inArray(productVariants.productId, productIds), + ), + ); + + const upids = variantRows + .map((r) => r.upid) + .filter((u): u is string => u !== null && u.trim().length > 0); + + const barcodes = variantRows + .map((r) => r.barcode) + .filter((b): b is string => b !== null && b.trim().length > 0); + + // Step 3: Republish snapshots for all affected products. + const publishResult = await publishProductsSetBased(db, { + brandId, + productIds, + }); + + logger.info("Fan-out publish complete", { + entityType, + entityId, + ...publishResult, + }); + + // Step 4: Revalidate DPP cache for affected passports and barcodes. + // Fire-and-forget: revalidation failures don't affect publish correctness. + await Promise.allSettled([ + revalidatePassports(upids), + revalidateBarcodes(brandId, barcodes), + ]); + + return { + productCount: productIds.length, + versionsCreated: publishResult.versionsCreated, + versionsSkippedUnchanged: publishResult.versionsSkippedUnchanged, + }; + }, +}); diff --git a/packages/jobs/src/trigger/catalog/index.ts b/packages/jobs/src/trigger/catalog/index.ts new file mode 100644 index 00000000..73e5b8c0 --- /dev/null +++ b/packages/jobs/src/trigger/catalog/index.ts @@ -0,0 +1,2 @@ +export { catalogFanOut } from "./fan-out"; +export type { CatalogEntityType, CatalogFanOutPayload } from "./fan-out"; diff --git a/packages/jobs/src/trigger/index.ts b/packages/jobs/src/trigger/index.ts index e2e7e071..b85b2104 100644 --- a/packages/jobs/src/trigger/index.ts +++ b/packages/jobs/src/trigger/index.ts @@ -15,3 +15,6 @@ export { syncIntegration, integrationSyncScheduler } from "./integrations"; // Certification expiry reminder task export { certificationExpiryReminder } from "./certification-expiry-reminder"; + +// Catalog fan-out task +export { catalogFanOut } from "./catalog"; From 1ce0cc3cb0cab0527a15ac10c375791e467f393c Mon Sep 17 00:00:00 2001 From: Raf Mevis Date: Thu, 5 Mar 2026 22:39:12 +0100 Subject: [PATCH 7/7] Review catalog fan-out issues --- .../integration/trpc/catalog-fan-out.test.ts | 308 ++++++++++++++++++ apps/api/src/trpc/routers/catalog/index.ts | 256 +++++++++------ .../src/queries/products/catalog-fan-out.ts | 137 ++++---- .../product-resolution.test.ts | 70 ++++ packages/jobs/src/trigger/catalog/fan-out.ts | 104 +++--- 5 files changed, 657 insertions(+), 218 deletions(-) create mode 100644 apps/api/__tests__/integration/trpc/catalog-fan-out.test.ts create mode 100644 packages/jobs/__tests__/unit/catalog-fan-out/product-resolution.test.ts diff --git a/apps/api/__tests__/integration/trpc/catalog-fan-out.test.ts b/apps/api/__tests__/integration/trpc/catalog-fan-out.test.ts new file mode 100644 index 00000000..17fa44ea --- /dev/null +++ b/apps/api/__tests__/integration/trpc/catalog-fan-out.test.ts @@ -0,0 +1,308 @@ +/** + * Integration Tests: Catalog Router Fan-Out + * + * Verifies that catalog deletes enqueue background fan-out jobs with the + * affected published product IDs captured before destructive FK updates. + */ + +// Load setup first (loads .env.test and configures cleanup) +import "../../setup"; + +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import * as schema from "@v1/db/schema"; +import { createTestBrand, createTestUser, testDb } from "@v1/db/testing"; +import { eq } from "drizzle-orm"; +import type { AuthenticatedTRPCContext } from "../../../src/trpc/init"; + +type TriggerCall = { + id: string; + payload: { + brandId: string; + entityType: string; + entityId: string; + productIds?: string[]; + }; + options?: { + concurrencyKey?: string; + delay?: string; + }; +}; + +const triggerCalls: TriggerCall[] = []; + +const triggerMock = mock( + async ( + id: string, + payload: TriggerCall["payload"], + options?: TriggerCall["options"], + ) => { + triggerCalls.push({ id, payload, options }); + return { id: `run_${triggerCalls.length}` } as const; + }, +); + +mock.module("@trigger.dev/sdk/v3", () => ({ + tasks: { + trigger: triggerMock, + }, +})); + +import { catalogRouter } from "../../../src/trpc/routers/catalog"; + +/** + * Build a stable short suffix for test record names. + */ +function randomSuffix(): string { + // Keep handles and names unique across test cases. + return Math.random().toString(36).slice(2, 10); +} + +/** + * Create a mock authenticated tRPC context for catalog router calls. + */ +function createMockContext(options: { + brandId: string; + userEmail: string; + userId: string; +}): AuthenticatedTRPCContext & { brandId: string } { + // Provide the minimum authenticated shape needed by the router middleware. + return { + user: { + id: options.userId, + email: options.userEmail, + app_metadata: {}, + user_metadata: {}, + aud: "authenticated", + created_at: new Date().toISOString(), + } as any, + brandId: options.brandId, + role: "owner", + db: testDb, + loaders: {} as any, + supabase: {} as any, + supabaseAdmin: null, + geo: { ip: null }, + }; +} + +/** + * Create a brand membership for the test user. + */ +async function createBrandMembership( + brandId: string, + userId: string, +): Promise { + // Authorize the caller against the brand-scoped procedures. + await testDb.insert(schema.brandMembers).values({ + brandId, + userId, + role: "owner", + }); +} + +/** + * Insert a product with an optional manufacturer link. + */ +async function createProduct(options: { + brandId: string; + manufacturerId?: string | null; + name: string; + status: "published" | "scheduled" | "unpublished"; +}): Promise { + // Seed a product row that can participate in catalog fan-out lookups. + const productId = crypto.randomUUID(); + + await testDb.insert(schema.products).values({ + id: productId, + brandId: options.brandId, + manufacturerId: options.manufacturerId ?? null, + name: options.name, + productHandle: `product-${randomSuffix()}`, + status: options.status, + }); + + return productId; +} + +/** + * Insert a variant for the supplied product. + */ +async function createVariant(productId: string): Promise { + // Create a variant row for variant-level material fan-out paths. + const variantId = crypto.randomUUID(); + + await testDb.insert(schema.productVariants).values({ + id: variantId, + productId, + sku: `SKU-${randomSuffix()}`, + upid: `UPID-${randomSuffix()}`, + }); + + return variantId; +} + +describe("Catalog Router Fan-Out", () => { + let brandId: string; + let userEmail: string; + let userId: string; + + beforeEach(async () => { + // Reset the queued trigger calls for each test case. + triggerCalls.length = 0; + + brandId = await createTestBrand("Catalog Fan-Out Router Brand"); + userEmail = `catalog-fan-out-${randomSuffix()}@example.com`; + userId = await createTestUser(userEmail); + await createBrandMembership(brandId, userId); + }); + + it("captures affected published products before deleting a manufacturer", async () => { + // Delete a manufacturer after linking it to both published and unpublished products. + const manufacturerId = crypto.randomUUID(); + await testDb.insert(schema.brandManufacturers).values({ + id: manufacturerId, + brandId, + name: `Manufacturer ${randomSuffix()}`, + }); + + const publishedProductId = await createProduct({ + brandId, + manufacturerId, + name: "Published Manufacturer Product", + status: "published", + }); + await createProduct({ + brandId, + manufacturerId, + name: "Unpublished Manufacturer Product", + status: "unpublished", + }); + + const ctx = createMockContext({ brandId, userEmail, userId }); + await catalogRouter.createCaller(ctx).manufacturers.delete({ + id: manufacturerId, + }); + + expect(triggerCalls).toHaveLength(1); + expect(triggerCalls[0]).toEqual({ + id: "catalog-fan-out", + payload: { + brandId, + entityType: "manufacturer", + entityId: manufacturerId, + productIds: [publishedProductId], + }, + options: { + concurrencyKey: brandId, + delay: "45s", + }, + }); + + const [product] = await testDb + .select({ manufacturerId: schema.products.manufacturerId }) + .from(schema.products) + .where(eq(schema.products.id, publishedProductId)); + + expect(product?.manufacturerId).toBeNull(); + }); + + it("captures published product and variant material references before deleting a certification", async () => { + // Delete a certification after linking it through both product and variant materials. + const certificationId = crypto.randomUUID(); + const productMaterialId = crypto.randomUUID(); + const variantMaterialId = crypto.randomUUID(); + + await testDb.insert(schema.brandCertifications).values({ + id: certificationId, + brandId, + title: `Certification ${randomSuffix()}`, + }); + + await testDb.insert(schema.brandMaterials).values([ + { + id: productMaterialId, + brandId, + name: `Product Material ${randomSuffix()}`, + certificationId, + }, + { + id: variantMaterialId, + brandId, + name: `Variant Material ${randomSuffix()}`, + certificationId, + }, + ]); + + const productLinkedProductId = await createProduct({ + brandId, + name: "Published Product Material Product", + status: "published", + }); + const variantLinkedProductId = await createProduct({ + brandId, + name: "Published Variant Material Product", + status: "published", + }); + const unpublishedProductId = await createProduct({ + brandId, + name: "Unpublished Certification Product", + status: "unpublished", + }); + + await testDb.insert(schema.productMaterials).values([ + { + productId: productLinkedProductId, + brandMaterialId: productMaterialId, + }, + { + productId: unpublishedProductId, + brandMaterialId: productMaterialId, + }, + ]); + + const variantId = await createVariant(variantLinkedProductId); + await testDb.insert(schema.variantMaterials).values({ + variantId, + brandMaterialId: variantMaterialId, + }); + + const ctx = createMockContext({ brandId, userEmail, userId }); + await catalogRouter.createCaller(ctx).certifications.delete({ + id: certificationId, + }); + + expect(triggerCalls).toHaveLength(1); + + const queuedProductIds = [...(triggerCalls[0]?.payload.productIds ?? [])].sort(); + expect(triggerCalls[0]).toMatchObject({ + id: "catalog-fan-out", + payload: { + brandId, + entityType: "certification", + entityId: certificationId, + }, + options: { + concurrencyKey: brandId, + delay: "45s", + }, + }); + expect(queuedProductIds).toEqual( + [productLinkedProductId, variantLinkedProductId].sort(), + ); + + const materials = await testDb + .select({ + certificationId: schema.brandMaterials.certificationId, + id: schema.brandMaterials.id, + }) + .from(schema.brandMaterials) + .where(eq(schema.brandMaterials.brandId, brandId)); + + expect(materials).toEqual( + expect.arrayContaining([ + { id: productMaterialId, certificationId: null }, + { id: variantMaterialId, certificationId: null }, + ]), + ); + }); +}); diff --git a/apps/api/src/trpc/routers/catalog/index.ts b/apps/api/src/trpc/routers/catalog/index.ts index 3ed2175f..40f7e7c3 100644 --- a/apps/api/src/trpc/routers/catalog/index.ts +++ b/apps/api/src/trpc/routers/catalog/index.ts @@ -1,5 +1,3 @@ -import type { Database } from "@v1/db/client"; -import { tasks } from "@trigger.dev/sdk/v3"; /** * Catalog router implementation. * @@ -16,6 +14,8 @@ import { tasks } from "@trigger.dev/sdk/v3"; * All endpoints follow a consistent CRUD pattern using shared helper * functions to minimize code duplication and ensure uniform error handling. */ +import { tasks } from "@trigger.dev/sdk/v3"; +import type { Database } from "@v1/db/client"; import { batchCreateBrandAttributeValues, countBrandAttributeValueVariantReferences, @@ -54,6 +54,10 @@ import { updateOperator, updateSeason, } from "@v1/db/queries/catalog"; +import { + findPublishedProductIdsByCertification, + findPublishedProductIdsByManufacturer, +} from "@v1/db/queries/products"; import { batchCreateBrandAttributeValuesSchema, createBrandAttributeSchema, @@ -118,6 +122,52 @@ import { /** tRPC context with guaranteed brand ID from middleware */ type BrandContext = AuthenticatedTRPCContext & { brandId: string }; +type CatalogFanOutEntityType = + | "manufacturer" + | "material" + | "certification" + | "operator"; + +type CatalogDeleteProductIdsResolver = ( + db: Database, + brandId: string, + entityId: string, +) => Promise; + +type CatalogFanOutConfig = { + entityType: CatalogFanOutEntityType; + resolveDeleteProductIds?: CatalogDeleteProductIdsResolver; +}; + +type CreateProcedureOptions = { + afterSuccess?: (args: { + brandCtx: BrandContext; + input: TInput; + result: any; + }) => Promise | void; +}; + +type UpdateProcedureOptions = { + afterSuccess?: (args: { + brandCtx: BrandContext; + input: TInput; + result: any; + }) => Promise | void; +}; + +type DeleteProcedureOptions = { + beforeDelete?: (args: { + brandCtx: BrandContext; + input: TInput; + }) => Promise | TBeforeDelete; + afterSuccess?: (args: { + brandCtx: BrandContext; + input: TInput; + result: any; + beforeDeleteData: TBeforeDelete | undefined; + }) => Promise | void; +}; + /** * Creates a standardized list procedure for brand catalog resources. * @@ -172,10 +222,12 @@ function createCreateProcedure( createFn: (db: Database, brandId: string, input: any) => Promise, resourceName: string, transformInput?: (input: any) => any, + options?: CreateProcedureOptions, ) { return brandWriteProcedure .input(schema) .mutation(async ({ ctx, input }) => { + // Execute the create mutation and run any configured success hooks. const brandCtx = ctx as BrandContext; try { const transformedInput = transformInput ? transformInput(input) : input; @@ -184,6 +236,11 @@ function createCreateProcedure( brandCtx.brandId, transformedInput, ); + await options?.afterSuccess?.({ + brandCtx, + input: input as TInput, + result, + }); return createEntityResponse(result); } catch (error) { throw wrapError(error, `Failed to create ${resourceName}`); @@ -215,10 +272,12 @@ function createUpdateProcedure( ) => Promise, resourceName: string, transformInput?: (input: any) => any, + options?: UpdateProcedureOptions, ) { return brandWriteProcedure .input(schema) .mutation(async ({ ctx, input }) => { + // Execute the update mutation and run any configured success hooks. const brandCtx = ctx as BrandContext; const typedInput = input as TInput; try { @@ -234,6 +293,11 @@ function createUpdateProcedure( if (!result) { throw notFound(resourceName, typedInput.id); } + await options?.afterSuccess?.({ + brandCtx, + input: typedInput, + result, + }); return createEntityResponse(result); } catch (error) { throw wrapError(error, `Failed to update ${resourceName}`); @@ -254,17 +318,26 @@ function createUpdateProcedure( * @param resourceName - Human-readable resource name for error messages * @returns tRPC mutation procedure with brand context and not-found handling */ -function createDeleteProcedure( +function createDeleteProcedure< + TInput extends { id: string }, + TBeforeDelete = undefined, +>( schema: any, deleteFn: (db: Database, brandId: string, id: string) => Promise, resourceName: string, + options?: DeleteProcedureOptions, ) { return brandWriteProcedure .input(schema) .mutation(async ({ ctx, input }) => { + // Resolve any pre-delete state before removing the resource. const brandCtx = ctx as BrandContext; const typedInput = input as TInput; try { + const beforeDeleteData = await options?.beforeDelete?.({ + brandCtx, + input: typedInput, + }); const result = await deleteFn( brandCtx.db, brandCtx.brandId, @@ -273,6 +346,12 @@ function createDeleteProcedure( if (!result) { throw notFound(resourceName, typedInput.id); } + await options?.afterSuccess?.({ + brandCtx, + input: typedInput, + result, + beforeDeleteData, + }); return createEntityResponse(result); } catch (error) { throw wrapError(error, `Failed to delete ${resourceName}`); @@ -293,14 +372,23 @@ function createDeleteProcedure( */ function enqueueCatalogFanOut( brandId: string, - entityType: "manufacturer" | "material" | "certification" | "operator", + entityType: CatalogFanOutEntityType, entityId: string, + options?: { + productIds?: string[]; + }, ): void { + // Schedule the fan-out on a per-brand queue so related writes serialize. tasks .trigger( "catalog-fan-out", - { brandId, entityType, entityId }, - { delay: "45s" }, + { + brandId, + entityType, + entityId, + productIds: options?.productIds, + }, + { delay: "45s", concurrencyKey: brandId }, ) .catch((err) => { console.error( @@ -325,7 +413,7 @@ function enqueueCatalogFanOut( * @param schemas - Zod schemas for each operation * @param operations - Database query functions for each operation * @param transformInput - Optional function to transform snake_case schema to camelCase DB input - * @param fanOutEntityType - If set, triggers catalog fan-out after successful mutations + * @param fanOutConfig - Optional fan-out hooks for background DPP refreshes * @returns tRPC router with list/create/update/delete endpoints * * @example @@ -359,8 +447,11 @@ function createCatalogResourceRouter( delete: (db: Database, brandId: string, id: string) => Promise; }, transformInput?: (input: any) => any, - fanOutEntityType?: "manufacturer" | "material" | "certification" | "operator", + fanOutConfig?: CatalogFanOutConfig, ) { + // Compose the shared CRUD helpers with optional catalog fan-out hooks. + const resolveDeleteProductIds = fanOutConfig?.resolveDeleteProductIds; + return createTRPCRouter({ list: createListProcedure( schemas.list, @@ -368,104 +459,61 @@ function createCatalogResourceRouter( resourceName, transformInput, ), - create: fanOutEntityType - ? brandWriteProcedure - .input(schemas.create) - .mutation(async ({ ctx, input }) => { - const brandCtx = ctx as BrandContext; - try { - const transformedInput = transformInput - ? transformInput(input) - : input; - const result = await operations.create( - brandCtx.db, - brandCtx.brandId, - transformedInput, - ); - const response = createEntityResponse(result); + create: createCreateProcedure( + schemas.create, + operations.create, + resourceName, + transformInput, + fanOutConfig + ? { + afterSuccess: ({ brandCtx, result }) => { enqueueCatalogFanOut( brandCtx.brandId, - fanOutEntityType, - (result as any).id, - ); - return response; - } catch (error) { - throw wrapError(error, `Failed to create ${resourceName}`); - } - }) - : createCreateProcedure( - schemas.create, - operations.create, - resourceName, - transformInput, - ), - update: fanOutEntityType - ? brandWriteProcedure - .input(schemas.update) - .mutation(async ({ ctx, input }) => { - const brandCtx = ctx as BrandContext; - const typedInput = input as { id: string }; - try { - const transformedInput = transformInput - ? transformInput(typedInput) - : typedInput; - const result = await operations.update( - brandCtx.db, - brandCtx.brandId, - typedInput.id, - transformedInput, + fanOutConfig.entityType, + (result as { id: string }).id, ); - if (!result) { - throw notFound(resourceName, typedInput.id); - } - const response = createEntityResponse(result); + }, + } + : undefined, + ), + update: createUpdateProcedure( + schemas.update, + operations.update, + resourceName, + transformInput, + fanOutConfig + ? { + afterSuccess: ({ brandCtx, input }) => { enqueueCatalogFanOut( brandCtx.brandId, - fanOutEntityType, - typedInput.id, + fanOutConfig.entityType, + input.id, ); - return response; - } catch (error) { - throw wrapError(error, `Failed to update ${resourceName}`); - } - }) - : createUpdateProcedure( - schemas.update, - operations.update, - resourceName, - transformInput, - ), - delete: fanOutEntityType - ? brandWriteProcedure - .input(schemas.delete) - .mutation(async ({ ctx, input }) => { - const brandCtx = ctx as BrandContext; - const typedInput = input as { id: string }; - try { - const result = await operations.delete( - brandCtx.db, - brandCtx.brandId, - typedInput.id, - ); - if (!result) { - throw notFound(resourceName, typedInput.id); - } - const response = createEntityResponse(result); + }, + } + : undefined, + ), + delete: createDeleteProcedure( + schemas.delete, + operations.delete, + resourceName, + fanOutConfig + ? { + beforeDelete: resolveDeleteProductIds + ? ({ brandCtx, input }) => + resolveDeleteProductIds(brandCtx.db, brandCtx.brandId, input.id) + : undefined, + afterSuccess: ({ brandCtx, input, beforeDeleteData }) => { enqueueCatalogFanOut( brandCtx.brandId, - fanOutEntityType, - typedInput.id, + fanOutConfig.entityType, + input.id, + { productIds: beforeDeleteData }, ); - return response; - } catch (error) { - throw wrapError(error, `Failed to delete ${resourceName}`); - } - }) - : createDeleteProcedure( - schemas.delete, - operations.delete, - resourceName, - ), + }, + } + : undefined, + ), }); } @@ -687,7 +735,7 @@ export const catalogRouter = createTRPCRouter({ delete: deleteMaterial, }, transformMaterialInput, - "material", + { entityType: "material" }, ), /** @@ -732,7 +780,7 @@ export const catalogRouter = createTRPCRouter({ delete: deleteOperator, }, transformOperatorInput, - "operator", + { entityType: "operator" }, ), /** @@ -756,7 +804,10 @@ export const catalogRouter = createTRPCRouter({ delete: deleteBrandManufacturer, }, transformManufacturerInput, - "manufacturer", + { + entityType: "manufacturer", + resolveDeleteProductIds: findPublishedProductIdsByManufacturer, + }, ), /** @@ -779,7 +830,10 @@ export const catalogRouter = createTRPCRouter({ delete: deleteCertification, }, transformCertificationInput, - "certification", + { + entityType: "certification", + resolveDeleteProductIds: findPublishedProductIdsByCertification, + }, ), }); diff --git a/packages/db/src/queries/products/catalog-fan-out.ts b/packages/db/src/queries/products/catalog-fan-out.ts index bb554c6b..08c917b0 100644 --- a/packages/db/src/queries/products/catalog-fan-out.ts +++ b/packages/db/src/queries/products/catalog-fan-out.ts @@ -21,43 +21,20 @@ import { } from "../../schema"; /** - * Find published product IDs affected by a manufacturer change. - * - * Manufacturers link directly to products via products.manufacturer_id. + * Resolve published product IDs for one or more material IDs. */ -export async function findPublishedProductIdsByManufacturer( +async function findPublishedProductIdsByMaterialIds( db: Database, brandId: string, - manufacturerId: string, + materialIds: string[], ): Promise { - const rows = await db - .select({ id: products.id }) - .from(products) - .where( - and( - eq(products.brandId, brandId), - eq(products.manufacturerId, manufacturerId), - eq(products.status, "published"), - ), - ); - - return rows.map((r) => r.id); -} + // Resolve both product-level and variant-level material references. + if (materialIds.length === 0) { + return []; + } -/** - * Find published product IDs affected by a material change. - * - * A material can appear on a product via product_materials or on individual - * variants via variant_materials. We resolve both paths and deduplicate. - */ -export async function findPublishedProductIdsByMaterial( - db: Database, - brandId: string, - materialId: string, -): Promise { const productIds = new Set(); - // Path 1: product_materials → products const viaProduct = await db .select({ productId: productMaterials.productId }) .from(productMaterials) @@ -65,16 +42,15 @@ export async function findPublishedProductIdsByMaterial( .where( and( eq(products.brandId, brandId), - eq(productMaterials.brandMaterialId, materialId), + inArray(productMaterials.brandMaterialId, materialIds), eq(products.status, "published"), ), ); - for (const r of viaProduct) { - productIds.add(r.productId); + for (const row of viaProduct) { + productIds.add(row.productId); } - // Path 2: variant_materials → product_variants → products const viaVariant = await db .select({ productId: productVariants.productId }) .from(variantMaterials) @@ -86,18 +62,57 @@ export async function findPublishedProductIdsByMaterial( .where( and( eq(products.brandId, brandId), - eq(variantMaterials.brandMaterialId, materialId), + inArray(variantMaterials.brandMaterialId, materialIds), eq(products.status, "published"), ), ); - for (const r of viaVariant) { - productIds.add(r.productId); + for (const row of viaVariant) { + productIds.add(row.productId); } return Array.from(productIds); } +/** + * Find published product IDs affected by a manufacturer change. + * + * Manufacturers link directly to products via products.manufacturer_id. + */ +export async function findPublishedProductIdsByManufacturer( + db: Database, + brandId: string, + manufacturerId: string, +): Promise { + const rows = await db + .select({ id: products.id }) + .from(products) + .where( + and( + eq(products.brandId, brandId), + eq(products.manufacturerId, manufacturerId), + eq(products.status, "published"), + ), + ); + + return rows.map((r) => r.id); +} + +/** + * Find published product IDs affected by a material change. + * + * A material can appear on a product via product_materials or on individual + * variants via variant_materials. We resolve both paths and deduplicate. + */ +export async function findPublishedProductIdsByMaterial( + db: Database, + brandId: string, + materialId: string, +): Promise { + // Delegate single-material lookups to the shared material resolver. + return findPublishedProductIdsByMaterialIds(db, brandId, [materialId]); +} + /** * Find published product IDs affected by a certification change. * @@ -110,6 +125,7 @@ export async function findPublishedProductIdsByCertification( brandId: string, certificationId: string, ): Promise { + // Resolve linked materials first, then reuse the shared material resolver. // Step 1: find materials that reference this certification const affectedMaterials = await db .select({ id: brandMaterials.id }) @@ -125,48 +141,11 @@ export async function findPublishedProductIdsByCertification( return []; } - const materialIds = affectedMaterials.map((m) => m.id); - const productIds = new Set(); - - // Path 1: product_materials → products - const viaProduct = await db - .select({ productId: productMaterials.productId }) - .from(productMaterials) - .innerJoin(products, eq(products.id, productMaterials.productId)) - .where( - and( - eq(products.brandId, brandId), - inArray(productMaterials.brandMaterialId, materialIds), - eq(products.status, "published"), - ), - ); - - for (const r of viaProduct) { - productIds.add(r.productId); - } - - // Path 2: variant_materials → product_variants → products - const viaVariant = await db - .select({ productId: productVariants.productId }) - .from(variantMaterials) - .innerJoin( - productVariants, - eq(productVariants.id, variantMaterials.variantId), - ) - .innerJoin(products, eq(products.id, productVariants.productId)) - .where( - and( - eq(products.brandId, brandId), - inArray(variantMaterials.brandMaterialId, materialIds), - eq(products.status, "published"), - ), - ); - - for (const r of viaVariant) { - productIds.add(r.productId); - } - - return Array.from(productIds); + return findPublishedProductIdsByMaterialIds( + db, + brandId, + affectedMaterials.map((material) => material.id), + ); } /** diff --git a/packages/jobs/__tests__/unit/catalog-fan-out/product-resolution.test.ts b/packages/jobs/__tests__/unit/catalog-fan-out/product-resolution.test.ts new file mode 100644 index 00000000..1d65e927 --- /dev/null +++ b/packages/jobs/__tests__/unit/catalog-fan-out/product-resolution.test.ts @@ -0,0 +1,70 @@ +/** + * Unit Tests: Catalog Fan-Out Product Resolution + * + * Verifies how the fan-out job chooses affected product IDs before publishing. + */ + +import { describe, expect, it, mock } from "bun:test"; +import { resolveCatalogFanOutProductIds } from "../../../src/trigger/catalog/fan-out"; + +describe("resolveCatalogFanOutProductIds", () => { + it("prefers product IDs supplied in the payload", async () => { + // Skip entity lookups when the API already captured the affected products. + const manufacturerResolver = mock(async () => { + throw new Error( + "Manufacturer resolver should not run when productIds are provided", + ); + }); + + const result = await resolveCatalogFanOutProductIds( + { + brandId: "brand_1", + entityType: "manufacturer", + entityId: "manufacturer_1", + productIds: ["product_1", "product_2", "product_1"], + }, + { + findPublishedProductIdsByCertification: mock(async () => []), + findPublishedProductIdsByManufacturer: manufacturerResolver as any, + findPublishedProductIdsByMaterial: mock(async () => []), + findPublishedProductIdsByOperator: mock(async () => []), + }, + ); + + expect(result).toEqual(["product_1", "product_2"]); + expect(manufacturerResolver).toHaveBeenCalledTimes(0); + }); + + it("falls back to the entity resolver when payload product IDs are absent", async () => { + // Delegate to the entity-specific resolver when no pre-delete IDs are available. + let seenBrandId: string | null = null; + let seenEntityId: string | null = null; + + const manufacturerResolver = mock( + async (_db: unknown, brandId: string, entityId: string) => { + seenBrandId = brandId; + seenEntityId = entityId; + return ["product_3"]; + }, + ); + + const result = await resolveCatalogFanOutProductIds( + { + brandId: "brand_2", + entityType: "manufacturer", + entityId: "manufacturer_2", + }, + { + findPublishedProductIdsByCertification: mock(async () => []), + findPublishedProductIdsByManufacturer: manufacturerResolver, + findPublishedProductIdsByMaterial: mock(async () => []), + findPublishedProductIdsByOperator: mock(async () => []), + }, + ); + + expect(result).toEqual(["product_3"]); + expect(manufacturerResolver).toHaveBeenCalledTimes(1); + expect(seenBrandId ?? "").toBe("brand_2"); + expect(seenEntityId ?? "").toBe("manufacturer_2"); + }); +}); diff --git a/packages/jobs/src/trigger/catalog/fan-out.ts b/packages/jobs/src/trigger/catalog/fan-out.ts index 40c1e4ba..2004d443 100644 --- a/packages/jobs/src/trigger/catalog/fan-out.ts +++ b/packages/jobs/src/trigger/catalog/fan-out.ts @@ -44,65 +44,93 @@ export interface CatalogFanOutPayload { brandId: string; entityType: CatalogEntityType; entityId: string; + productIds?: string[]; } +type CatalogFanOutProductResolvers = { + findPublishedProductIdsByCertification: typeof findPublishedProductIdsByCertification; + findPublishedProductIdsByManufacturer: typeof findPublishedProductIdsByManufacturer; + findPublishedProductIdsByMaterial: typeof findPublishedProductIdsByMaterial; + findPublishedProductIdsByOperator: typeof findPublishedProductIdsByOperator; +}; + +const defaultCatalogFanOutResolvers: CatalogFanOutProductResolvers = { + findPublishedProductIdsByCertification, + findPublishedProductIdsByManufacturer, + findPublishedProductIdsByMaterial, + findPublishedProductIdsByOperator, +}; + // ============================================================================= // TASK // ============================================================================= +/** + * Resolve the published product IDs for a catalog fan-out run. + */ +export async function resolveCatalogFanOutProductIds( + payload: CatalogFanOutPayload, + resolvers: CatalogFanOutProductResolvers = defaultCatalogFanOutResolvers, +): Promise { + // Prefer pre-delete product IDs captured by the API before FK nullification. + if (payload.productIds) { + return Array.from(new Set(payload.productIds)); + } + + switch (payload.entityType) { + case "manufacturer": + return resolvers.findPublishedProductIdsByManufacturer( + db, + payload.brandId, + payload.entityId, + ); + case "material": + return resolvers.findPublishedProductIdsByMaterial( + db, + payload.brandId, + payload.entityId, + ); + case "certification": + return resolvers.findPublishedProductIdsByCertification( + db, + payload.brandId, + payload.entityId, + ); + case "operator": + return resolvers.findPublishedProductIdsByOperator( + db, + payload.brandId, + payload.entityId, + ); + default: + logger.warn("Unknown entity type, skipping", { + entityType: payload.entityType, + }); + return []; + } +} + export const catalogFanOut = task({ id: "catalog-fan-out", - // Concurrency limit of 1 per brand prevents fan-outs for the same brand from - // running simultaneously and stepping on each other's snapshot writes. + // A per-brand concurrency key plus a queue limit of 1 prevents overlapping + // fan-outs for the same brand from stepping on each other's snapshot writes. queue: { name: "catalog-fan-out", - concurrencyLimit: 5, + concurrencyLimit: 1, }, run: async (payload: CatalogFanOutPayload) => { + // Resolve affected products, preferring any pre-delete IDs from the payload. const { brandId, entityType, entityId } = payload; logger.info("Starting catalog fan-out", { brandId, entityType, entityId }); - // Step 1: Resolve affected published product IDs. - let productIds: string[]; - switch (entityType) { - case "manufacturer": - productIds = await findPublishedProductIdsByManufacturer( - db, - brandId, - entityId, - ); - break; - case "material": - productIds = await findPublishedProductIdsByMaterial( - db, - brandId, - entityId, - ); - break; - case "certification": - productIds = await findPublishedProductIdsByCertification( - db, - brandId, - entityId, - ); - break; - case "operator": - productIds = await findPublishedProductIdsByOperator( - db, - brandId, - entityId, - ); - break; - default: - logger.warn("Unknown entity type, skipping", { entityType }); - return { skipped: true, reason: "unknown_entity_type" }; - } + const productIds = await resolveCatalogFanOutProductIds(payload); logger.info("Resolved affected products", { entityType, entityId, productCount: productIds.length, + resolvedFromPayload: Boolean(payload.productIds), }); if (productIds.length === 0) {