Database Schema

Merch database tables, enums, JSON column shapes, and access layer

Tables Overview

Catalog-backed merch tables across three schema files:

  • Core tables: packages/db/src/schema/merchTables.ts (including campaign satellite tables and audit log)
  • Design tables: packages/db/src/schema/merchDesignTables.ts
  • Catalog tables: packages/db/src/schema/merchCatalogTables.ts
  • Benchmark tables: packages/db/src/schema/merchBenchmarkTables.ts
TablePurpose
merch_campaignCore campaign identity and lifecycle (normalized — satellite tables hold branding, copy, etc.)
merch_campaign_brandingCampaign branding (logos, colors, partner info)
merch_campaign_studioStudio/selfie capture config (headlines, captions, carousel slides)
merch_campaign_legalLegal text (terms, privacy, cookie content)
merch_campaign_configShipping, payment, and misc settings
merch_campaign_copyFan-facing copy (headlines, thank-you messages, email templates)
merch_audit_logAudit trail for campaign changes (changeSetId for grouping)
catalog_productGlobal product catalog rows (sku, type, base pricing, sizing, personalization requirements)
catalog_product_imageProduct images managed by catalog product
catalog_renderer_configProduct renderer placement/background configuration
shop_productCampaign-specific attachment of a catalog product, with display/price/free overrides
merch_productDeprecated legacy product table retained only for migration compatibility
merch_assetS3 media assets (logo, hero, ai_template, overlay, backside_print)
merch_sessionFan session (photo ref, cart ref, shipping, payment state)
merch_selfieSelfie image + face detection + demographics per session
merch_itemUnified table representing cart items and order items (cart-side rows have order_id IS NULL, order-side rows have it set)
merch_session_analyticsSession analytics sidecar (steps, milestones, generated assets)
merch_session_generationPer-(session, design, productType) generation cache (AI art key, candidates, cost); product type is derived from the selected catalog product
merch_orderCompleted orders (customer, shipping, payment, fulfillment)
merch_designNamed design per campaign — supports a 3-level hierarchy (design → product variation → demographic variation)
merch_design_configPer-design AI config blob (prompt, assets, qualityTiers). productType = NULL = Level 1 default
merch_ai_modelAI model registry (endpoints, costs, parameters)
merch_shareShareable result links
merch_tracking_linkCampaign tracking links (UTM-style)
merch_tracking_eventIndividual tracking visit events
merch_benchmark_sessionAdmin benchmark run metadata (prompts, assets, counters, cost)
merch_benchmark_generationOne row per benchmark generation (selfie × prompt × model × params)
merch_campaign_visitIP-deduplicated campaign visits
merch_seed_testAI seed consistency tests
merch_seed_test_reviewSeed test review judgments

Enums

19 enums in packages/db/src/schema/merchEnums.ts:

EnumValues
merch_consent_typeimplied, checkbox
merch_activation_statusDRAFT, LIVE, ENDED
merch_shutdown_modeNONE, SOFT_CLOSE, EMERGENCY_CLOSE, ENDED
merch_user_rolecustomer, tester, preview
merch_order_modeLIVE, PREVIEW
merch_preview_typeDEMO, TEST
merch_order_statuspending, processing, completed, failed, cancelled, new, received, working_on, ready, shipped, delivered
merch_product_typeDeprecated legacy enum for old merch_product rows
merch_asset_typelogo, hero, mockup, ai_template, backside_print, studio_carousel, overlay
merch_payment_statuspending, paid, succeeded, failed, refunded
merch_fulfillment_statuspending, processing, shipped, delivered, cancelled
merch_face_errorno_face, multiple_faces, face_occluded, no_model
merch_lighting_errortoo_dark
merch_ai_model_statusactive, inactive, deprecated
merch_speed_categoryfast, medium, slow
merch_progress_indicator_typelinear, spinner
merch_source_typeselfie, upload
merch_genderfemale, male, not-distinctive
merch_age_groupchild, teen, 20s, 30s, 40s, elder

Key Table Details

merch_campaign (normalized)

The campaign table now holds core identity and lifecycle fields. Detail fields have been extracted into satellite tables:

  • Core: id, slug, name, talentName, environment, startDate, endDate, isActive, status, shutdownMode, shutdownStartedAt, shutdownEndsAt
  • Flags: requireNameOnCard, enableShareButton, enableSaveButton, enableLiveMockups
  • Audit: createdAt, updatedAt, deletedAt (soft delete)

Satellite tables (all FK to merch_campaign.id):

TableFields
merch_campaign_brandingpartnerLogoAssetId, heroImageAssetId, primaryColor, secondaryColor
merch_campaign_studiostudioHeadline, studioSelfieButtonLabel, studioUploadButtonLabel, studioBackLabel, studioSlide1-3ImageAssetId, studioSlide1-3Caption, studioVerificationHeadline/Subtext
merch_campaign_legallegalFooterConsentType, terms/privacy/cookie title+content+updatedAt
merch_campaign_configallowedCountries, currency, shippingFlatRate, shippingIntlRate, paymentMethods, mobileProductGrid, plaqueConfig, fanLocationText
merch_campaign_copydesktopTitle, cameraPermissionText, progressIndicatorType, thankYouMessage, successHeadline, emailSubjectLine, shareTextTemplate, supportEmail

Note: Generation-related fields (aiConfig, itemGenerationConfigs, upscaleConfig) have been removed from merch_campaign_config. All AI generation settings now live in merch_design_config.configJson.

merch_session

  • Session: id, campaignId (nullable pointer, mutable for campaign adoption), startPage (nullable URL of the origin page), role, createdAt, updatedAt, expiresAt (default now()+24h)
  • Photo ref: activeSelfieId (FK to merch_selfie.id — the selfie currently used for generation)
  • AI: aiArtKey, aiArtPrompt, aiArtCandidates (json), bgMaskKey, plaqueIllustrationKey (legacy)
  • Payment: paymentIntentId, consentGiven
  • Tracking: delayedEmail, ipAddress, userAgent

Note: Selfie image data (selfieKey, sourceType, faceDetected, faceError, lightingError, gender, ageGroup, peopleCount) and cart items (cartItems JSON) have been extracted into dedicated merch_selfie and merch_item (originally merch_cart_item) tables. Legacy columns remain on merch_session for backwards compatibility. campaignId is nullable to support multishop journey sessions where the session is created before campaign selection.

merch_selfie

Extracted selfie record, created on every upload/capture. The session's activeSelfieId points to the selfie currently used for generation.

  • Identity: id, sessionId
  • Image: selfieKey (S3 key), sourceType (selfie | upload)
  • Face detection: faceDetected (bool), faceError, lightingError
  • Demographics (populated by verifyImageWithGemini): gender, ageGroup, peopleCount
  • Audit: createdAt, updatedAt

merch_item

Unified table representing cart items and order items (cart-side rows have order_id IS NULL, order-side rows have it set). Replaces the old merch_session.cartItems JSON array and the deprecated merch_cart_item / merch_order_item tables.

  • Identity: id, sessionId, orderId (nullable — set on checkout)
  • Refs: selfieId (FK to merch_selfie), designId (FK to merch_design), productId/shopProductId (FK to shop_product)
  • Product: productName, designName, catalog product type, size, quantity, personalizationText (stores plaque engraving / custom fanName text)
  • Pricing: unitPriceMinorUnit, subtotalMinorUnit (currency inherited from campaign)
  • Images: imageKey (media filename or legacy AI art key), cleanImageKey (clean media filename for confirm page)
  • Sort: sortOrder
  • Audit: createdAt, updatedAt

merch_design

Named design belonging to a campaign. Supports a 3-level hierarchy via the self-referencing parentDesignId FK:

LevelparentDesignIdgender/ageGroupPurpose
Level 1 — DesignNULLNULLTop-level design with default generation settings for all products
Level 2 — Product Variationpoints to Level 1NULLOverrides template image, prompt, and AI model for a specific catalog product
Level 3 — Demographic Variationpoints to Level 2setOverrides template image, prompt, and AI model for a specific gender/age within a product variation
  • Identity: id, campaignId
  • Hierarchy: parentDesignId (self-referencing FK — non-null = child design)
  • Demographics (Level 3 only): gender (merch_gender), ageGroup (merch_age_group)
  • Config: name, catalogProductIds (text array — controls which catalog products the client app offers for this design), displayImageAssetId, displayImageUrl, sortOrder, isActive
  • Audit: createdAt, updatedAt

Resolution logic: During generation, resolveDesignConfig3Level walks the hierarchy:

  1. Loads the Level 1 base config — the first config row on the top-level design, regardless of its productType value (the productType column on Level 1 configs is ignored; whatever config exists is treated as the universal default)
  2. Finds a Level 2 product variation matching the requested catalog product ID (if any)
  3. Merges Level 2 overrides on top of Level 1
  4. Finds a Level 3 demographic variation matching the selfie's gender/ageGroup (if any)
  5. Merges Level 3 overrides on top
  6. Returns the merged config + effective design ID

Child configs only need to specify overridden fields — empty fields inherit from the parent level.

Legacy compatibility: Direct demographic sub-designs (Level 2 with gender/ageGroup, no intermediate product variation) are still supported as a fallback path.

merch_design_config

AI generation config blob for a design at any level.

  • Identity: id, designId (FK to merch_design)
  • Scope: productType (nullable — NULL = default config applying to all product types)
  • Content: previewImageUrl, configJson (JSONB)
  • Audit: createdAt, updatedAt

Level 1 designs have a single config row containing the full generation settings (template image, prompt, AI model, quality tiers, fan location text, overlay, back print). The productType value on this row is ignored at runtime — whatever config exists is treated as the universal default for all products. Legacy data may have a specific productType value (e.g. "tshirt") rather than NULL; this is harmless since the resolver ignores it.

Level 2/3 designs have a config row containing only override fields (template image, prompt, AI model). During resolution these are merged on top of the parent's config.

Unique index: (design_id, product_type) — enforced via manual upsert because PostgreSQL treats NULL as distinct in unique indexes.

merch_session_generation

Caches AI generation output keyed by (sessionId, designId, productType). Enables deduplication and avoids re-generation for the same selfie+design+product combination.

productType is retained in the cache key for fallback-chain compatibility; runtime product/design association uses the selected catalog product ID.

  • Identity: id, sessionId, designId, productType
  • Selfie ref: selfieId (FK to merch_selfie, nullable for backward compat), selfieKey
  • Output: aiArtKey, aiArtPrompt, aiArtCandidates (jsonb), finalImgKey
  • Cost: generationCost
  • Audit: createdAt, updatedAt

merch_order

  • Order: id, orderNumber (unique), campaignId, sessionId, role, status, mode, previewType
  • Customer: email, firstName, lastName
  • Address: addressLine1/2, city, state, postalCode, country
  • Pricing: subtotal, shippingCost, total, currency (ISO 4217 — propagated from merch_campaign_config.currency)
  • Payment: paymentIntentId, stripeCustomerId, paymentStatus
  • Consent: consentGiven, consentTimestamp
  • Content: aiArtKey, sourceType
  • Fulfillment: fulfillmentStatus, trackingNumber, holdInfo (json)

JSON Column Shapes

configJson (merch_design_config)

Validated against GarmentDesignConfigSchema or PlaqueDesignConfigSchema (Zod) in packages/merch/srv/src/design-config-schemas.ts.

Garment example (tshirt / hoodie — Level 1 full config):

{
  "templateImageUrl": "https://s3.../merch/template.png",
  "templateImageAssetId": "asset-abc123",
  "prompt": "A vivid portrait of {fanName} as a superhero...",
  "modelEndpoint": "fal-ai/gpt-image-1.5/edit",
  "qualityTiers": ["low", "medium", "high"],
  "fanLocationText": "person on the left",
  "overlayImageUrl": "https://s3.../overlay.png",
  "backPrintImageUrl": "https://s3.../backprint.png"
}

Level 2/3 override example (only overridden fields):

{
  "templateImageUrl": "https://s3.../hoodie-specific-template.png",
  "prompt": "Same style but optimized for hoodie layout..."
}

Note: sourceImageUrl / sourceImageAssetId are deprecated aliases for templateImageUrl / templateImageAssetId. The runtime reads templateImageUrl ?? sourceImageUrl for backward compatibility.

Plaque example:

{
  "prompt": "Create a platinum vinyl record artwork...",
  "qualityTiers": ["low", "low"]
}

qualityTiers is an optional array of "low" | "medium" | "high" values. Each entry produces one generation attempt at that quality level. Examples:

  • ["low", "medium", "high"] → 3 attempts (one at each tier) — fans choose from all candidates
  • ["low", "low"] → 2 low-quality attempts
  • Omitted → falls back to DEFAULT_QUALITY_TIERS = ["low"] (single low-quality generation)

aiArtCandidates (merch_session / merch_session_generation)

[
  { "artKey": "merch/ai-candidates/sess-1-best-1234.png", "url": "https://...", "score": 0.92 },
  { "artKey": "merch/ai-candidates/sess-2-alt-1234.png", "url": "https://...", "score": 0.85 },
  { "artKey": "merch/ai-candidates/sess-3-alt-1234.png", "url": "https://...", "score": 0.71 }
]

Sorted by score (character consistency likeness, 0–1). The fan selects their preferred candidate via POST /api/merch/ai/select.

shippingInfo (merch_session)

{
  "email": "fan@example.com",
  "firstName": "John",
  "lastName": "Doe",
  "addressLine1": "123 Main St",
  "city": "New York",
  "state": "NY",
  "postalCode": "10001",
  "country": "US"
}

plaqueConfig (merch_campaign)

{
  "vinyl": { "x": 200, "y": 200, "size": 280 },
  "prompt": "Create a platinum vinyl record artwork.",
  "fanName": "MARK THOMPSON",
  "standardPricing": { "price": 299, "shipping": 35 },
  "deluxePricing": { "price": 539, "shipping": 35 },
  "standardImages": { "basePlaqueUrl": "...", "recordFrameUrl": "...", "artistRefUrl": "..." },
  "deluxeImages": { "basePlaqueUrl": "...", "recordFrameUrl": "...", "artistRefUrl": "..." }
}

Access Layer

13 access files in packages/db/src/access/merch/:

FileFunctions
merch-campaign.tsgetMerchCampaignBySlug, getMerchCampaignById, listActiveMerchCampaigns, getMerchCampaignLifecycle
merch-catalog.tscreate/list/update catalog products, product images, renderer config
shop-product.tsattach/list/update/reorder catalog products for a campaign
merch-asset.tsgetMerchAssetById, getMerchAssetsByIds, getMerchAssetByKey, createMerchAsset
merch-session.tscreateMerchSession, getMerchSessionById, updateMerchSession
merch-selfie.tscreateMerchSelfie, getMerchSelfieById, listMerchSelfiesBySession, getActiveSelfie, updateMerchSelfie
merch-item.tsaddItemToCart, replaceCartItems, listInCartItems, removeItem, clearCartItems, updateItemQuantity, updateItemImages, attachCartItemSnapshotToOrder, listMerchOrderItemsByOrder
merch-session-analytics.tscreateMerchSessionAnalytics, getMerchAnalyticsBySessionId, updateMerchAnalyticsStep, updateMerchAnalyticsMilestone, appendMerchGeneratedAsset
merch-order.tscreateMerchOrder, getMerchOrderById, getMerchOrderBySession, updateMerchOrderStatus
merch-share.tscreateMerchShare, getMerchShareById
merch-tracking.tsgetMerchTrackingLinkByCampaignSlug, incrementMerchTrackingVisit, recordMerchTrackingEvent
merch-campaign-visit.tsrecordMerchCampaignVisit
merch-ai-model.tsgetActiveMerchAIModels, getMerchAIModelByEndpoint

Design access in merch-design.ts (also in access/merch/):

FunctionDescription
listDesignsByCampaignTop-level designs only (parentDesignId IS NULL)
listDesignsByCatalogProductIdTop-level designs for a specific catalog product (parentDesignId IS NULL)
listAllDesignsByCampaignAll designs including sub-designs
getDesignByIdSingle design by ID
createDesignInsert a new design row
updateDesignUpdate design fields
listSubDesigns(parentDesignId)Sub-designs for a parent design
resolveDesignConfig3Level(designId, productType, demographics, fallbackChain, catalogProductId)Primary resolver — walks the 3-level hierarchy. Level 1 loads the first config from the top-level design (ignores productType on the row — universal default). Level 2/3 merge overrides on top. Returns { effectiveDesignId, configJson }
resolveDesignForDemographics(parentDesignId, demographics)Legacy: find best-matching demographic sub-design
resolveDesignConfigWithFallback(effectiveDesignId, parentDesignId, productType, fallbackChain)Legacy: resolve config with parent fallback
resolveDesignConfigRow(designId, productType, fallbackChain)Resolve config for a design with product-type fallback chain
upsertDesignConfigCreate-or-update a design config row

All exported from packages/db/src/index.ts.

Indexes

  • Unique: merch_campaign(slug, talentName), merch_tracking_link(campaignId, slug), merch_campaign_visit(campaignId, ipAddress), merch_ai_model(endpointId), merch_session_analytics(sessionId, campaignId)
  • Catalog product sku: catalog_product(sku) unique
  • Shop product attachment: shop_product(campaign_id, catalog_product_id) unique
  • Design config: merch_design_config(design_id, product_type) — enforced via manual upsert (NULL product_type = default config)
  • Sub-design uniqueness: merch_design(parent_design_id, gender, age_group) NULLS NOT DISTINCT WHERE parent_design_id IS NOT NULL (prevents duplicate variations)
  • Parent lookup: merch_design(parent_design_id)
  • Generation cache: merch_session_generation(session_id, design_id, product_type) unique
  • Selfie lookup: merch_selfie(session_id), merch_selfie(selfie_key)
  • Cart lookup: merch_cart_item(session_id), merch_cart_item(selfie_id), merch_cart_item(product_id)
  • Soft delete filter: merch_campaign(deletedAt) — queries filter deletedAt IS NULL
  • IP dedup: merch_campaign_visit uses onConflictDoNothing() for upsert
  • Atomic counters: tracking link visit uses SET visitCount = visitCount + 1

Migrations

MigrationDescription
0044_add_design_tables.sqlInitial merch_design, merch_design_config, merch_session_generation tables
0045_stable_product_ids.sqlLegacy unique index on merch_product(campaign_id, type)
0046_merch_session_demographics.sqlmerch_gender / merch_age_group enums; gender, age_group, people_count on merch_session
0047_extract_selfie_cart_item.sqlmerch_selfie table, merch_cart_item table, active_selfie_id on session, selfie_id on generation/order_item
0048_design_demographics.sqlparent_design_id, gender, age_group on merch_design + sub-design unique index
0049_merch_campaign_normalization.sqlNormalizes campaign into satellite tables (branding, studio, legal, config, copy)
0050_merch_audit_log.sqlAudit log table for campaign changes
0051_remove_generation_from_campaign.sqlRemoves deprecated ai_config, itemGenerationConfigs, upscaleConfig from merch_campaign_config
0052_design_default_config.sqlCreates productType = NULL default configs for top-level designs (Level 1 hierarchy support)

Data migrations run automatically on deploy via packages/db/src/migrate.tsrunDataMigrations(). The registry is in packages/db/src/data-migrations/registry.ts.

Data MigrationDescription
0001-migrate-designsBack-fills merch_design rows from legacy campaign JSON
0002-extract-selfies-and-cart-itemsMigrates selfie/cart data from session columns into merch_selfie / merch_cart_item rows

S3 Key Patterns

ContentS3 Key Pattern
Selfiesmerch/selfie/{filename}
AI art candidatesmerch/ai-candidates/{sessionId}-{attempt}-{timestamp}.png
Final AI artmerch/ai-generated/{sessionId}-{timestamp}.png
Background masksmerch/bg-masks/mask-{sessionId}-{timestamp}.png
Catalog rendersmerch/renders/render-{sessionId}-{catalogProductId}-{preview|clean}-{timestamp}.webp
Mockups / plaque illustrationsLegacy paths retained for older endpoints