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/__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/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/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/api/src/trpc/routers/catalog/index.ts b/apps/api/src/trpc/routers/catalog/index.ts index 7ed11420..40f7e7c3 100644 --- a/apps/api/src/trpc/routers/catalog/index.ts +++ b/apps/api/src/trpc/routers/catalog/index.ts @@ -1,4 +1,3 @@ -import type { Database } from "@v1/db/client"; /** * Catalog router implementation. * @@ -15,6 +14,8 @@ import type { Database } from "@v1/db/client"; * 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, @@ -53,6 +54,10 @@ import { updateOperator, updateSeason, } from "@v1/db/queries/catalog"; +import { + findPublishedProductIdsByCertification, + findPublishedProductIdsByManufacturer, +} from "@v1/db/queries/products"; import { batchCreateBrandAttributeValuesSchema, createBrandAttributeSchema, @@ -117,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. * @@ -171,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; @@ -183,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}`); @@ -214,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 { @@ -233,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}`); @@ -253,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, @@ -272,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}`); @@ -279,6 +359,45 @@ 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: 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, + productIds: options?.productIds, + }, + { delay: "45s", concurrencyKey: brandId }, + ) + .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 +413,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 fanOutConfig - Optional fan-out hooks for background DPP refreshes * @returns tRPC router with list/create/update/delete endpoints * * @example @@ -327,7 +447,11 @@ function createCatalogResourceRouter( delete: (db: Database, brandId: string, id: string) => Promise; }, transformInput?: (input: any) => any, + fanOutConfig?: CatalogFanOutConfig, ) { + // Compose the shared CRUD helpers with optional catalog fan-out hooks. + const resolveDeleteProductIds = fanOutConfig?.resolveDeleteProductIds; + return createTRPCRouter({ list: createListProcedure( schemas.list, @@ -340,17 +464,55 @@ function createCatalogResourceRouter( operations.create, resourceName, transformInput, + fanOutConfig + ? { + afterSuccess: ({ brandCtx, result }) => { + enqueueCatalogFanOut( + brandCtx.brandId, + fanOutConfig.entityType, + (result as { id: string }).id, + ); + }, + } + : undefined, ), update: createUpdateProcedure( schemas.update, operations.update, resourceName, transformInput, + fanOutConfig + ? { + afterSuccess: ({ brandCtx, input }) => { + enqueueCatalogFanOut( + brandCtx.brandId, + fanOutConfig.entityType, + input.id, + ); + }, + } + : 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, + fanOutConfig.entityType, + input.id, + { productIds: beforeDeleteData }, + ); + }, + } + : undefined, ), }); } @@ -573,6 +735,7 @@ export const catalogRouter = createTRPCRouter({ delete: deleteMaterial, }, transformMaterialInput, + { entityType: "material" }, ), /** @@ -617,6 +780,7 @@ export const catalogRouter = createTRPCRouter({ delete: deleteOperator, }, transformOperatorInput, + { entityType: "operator" }, ), /** @@ -640,6 +804,10 @@ export const catalogRouter = createTRPCRouter({ delete: deleteBrandManufacturer, }, transformManufacturerInput, + { + entityType: "manufacturer", + resolveDeleteProductIds: findPublishedProductIdsByManufacturer, + }, ), /** @@ -662,6 +830,10 @@ export const catalogRouter = createTRPCRouter({ delete: deleteCertification, }, transformCertificationInput, + { + entityType: "certification", + resolveDeleteProductIds: findPublishedProductIdsByCertification, + }, ), }); 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/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/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/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 = ({