Business Logic

AI generation, image processing, order creation, email, and lifecycle

Overview

25 service files in packages/merch/srv/src/ (package @zooly/merch-srv) covering the full backend logic. Payment integration is handled by @zooly/srv-stripe-payment.

AI Art Generation

generateConsistentAIArt (generate-ai-art.ts)

Sequential generation with character consistency scoring, configurable quality tiers, and user-driven regeneration:

  1. Receive a qualityTiers: QualityTier[] array (e.g. ["low", "medium", "high"]) from the resolved design config; fall back to DEFAULT_QUALITY_TIERS if not set
  2. Iterate once per entry in qualityTiers, generating one image per tier at that quality level via generateAIArt() (FAL.AI)
  3. Post-process each result with downloadAndProcessAIImage(), upload to S3, then score with validateCharacterConsistency() (Gemini vision)
  4. Keep all generated candidates; return all as candidates sorted by likeness score
  5. New candidates are prepended to any existing ones from prior generations in the session's aiArtCandidates
  6. The user can click "Regenerate" to request another attempt with forceRegenerate: true, bypassing the server-side generation cache; all prior generations remain selectable

The client shows all accumulated candidates (newest first) and lets the fan select their preferred art via POST /api/merch/ai/select, which updates the session's aiArtKey to the chosen candidate.

Quality tier configuration: Admin-configurable per design config. The array length determines how many generation attempts run; the values control quality per attempt. Examples:

  • ["low", "medium", "high"] — 3 attempts at ascending quality
  • ["low", "low"] — 2 fast/cheap attempts

Design Resolution — 3-Level Hierarchy

Designs follow a 3-level override hierarchy. At runtime the system merges configs from each level, with child values overriding parent values for non-empty fields.

Level 1: Design (base generation settings — applies to ALL products)
  └─ Level 2: Product Variation (overrides template image, prompt, AI model for a specific catalog product)
       └─ Level 3: Demographic Variation (overrides for a specific gender/age within a product variation)

resolveDesignConfig3Level (merch-design.ts)

Primary resolver — the single entry point for all generation config resolution:

  1. Level 1 base: Loads the first config row from the top-level design. The productType column on this row is ignored — it is always treated as the universal default for all products (legacy data may have a specific type like "tshirt" instead of NULL; this is harmless).
  2. Level 2 product override: Finds a child design matching the requested catalogProductId (with gender and ageGroup both NULL). If found, merges its configJson on top of Level 1. The catalog product's productType is still passed through for config fallback-chain logic.
  3. Level 3 demographic override: If Level 2 was found, looks for a grandchild design matching the selfie's { gender, ageGroup }. If found, merges on top.
  4. Legacy fallback: If no Level 2 product variation exists, checks for direct demographic sub-designs of the Level 1 design (backward compatibility).

Returns { effectiveDesignId, configJson } — the merged config and the ID of the most-specific design that matched.

Config merging (mergeConfigJson)

mergeConfigJson(parent, child) produces a merged config where non-empty child values override parent values. "Empty" means undefined, null, or "". This means a Level 2 config only needs to specify the fields it wants to override — everything else inherits from Level 1.

Legacy resolvers (still exported)

FunctionDescription
resolveDesignConfigRowResolves config for a (designId, productType) pair with fallback chain
resolveDesignForDemographicsFinds best-matching demographic sub-design (priority: both → gender → ageGroup → unset → parent)
resolveDesignConfigWithFallbackResolves config from effective design, falls back to parent design

Campaign-Agnostic Session & Adoption

In standard campaigns, a session is tightly bound to a campaign at creation. To support multishop Superstore journeys, sessions are campaign-agnostic—the database merch_session.campaign_id column is nullable.

1. Mode-Dependent Lifecycle

The creation and adoption of sessions depends on the entry point:

  • Direct Entry (Regular/Experience Campaign): Creates a fresh, campaign-bound session immediately via POST /session/create with campaignSlug set.
  • Multishop Entry (Superstore Aggregator): The Superstore lives at /merch/superstore/:slug as a top-level SPA route. Tapping a sub-store card calls startSuperstoreJourney(slug) which creates a campaign-null session via POST /session/create (omitting campaignSlug but storing /merch/superstore/{slug} as startPage) and stores journeySessionId + journeySuperstoreSlug in Zustand. The shopper is then navigated in-SPA to the sub-store with ?utm=superstore-{slug} (no ?s= — the session id is in memory).

2. Session Adoption (POST /api/merch/session/update)

When the sub-store landing page loads, if it detects utm=superstore-* and a journeySessionId in the Zustand store, it calls adoptSession which hits PATCH /api/merch/session/update with campaignSlug. If the requested campaignSlug differs from the session's existing campaignId:

  1. The server validates that the campaign is active and matches the environment.
  2. The server updates merch_session.campaign_id to point to the new campaign.
  3. The server clears per-campaign scratch fields so they can regenerate specifically for this campaign:
    • designId, pendingItem
    • aiArtKey, aiArtRawKey, aiArtPrompt, aiArtCandidates
    • finalImgKey, bgMaskKey, and all illustration/mockup image keys
  4. The server preserves all identity fields, meaning the user's selfie, email, userId, shipping info, consent flags, and location are reused seamlessly across different sub-stores.
  5. The server ensures a corresponding merch_session_analytics row exists for the new campaign.

3. Per-Campaign Analytics Funnels

Even though a single journey session spans multiple sub-stores/campaigns, analytics must remain granular per campaign.

  • merch_session_analytics relaxes the session_id uniqueness and is instead keyed on a composite unique key (session_id, campaign_id).
  • Standard sessions produce exactly 1 analytics row. Multishop sessions produce 1 row per campaign they adopt, preserving the integrity of individual campaign funnels.

AI generate route integration

The /api/merch/ai/generate route:

  1. Design selection: Priority is explicit designId in request > auto-select by catalog product ID via listDesignsByCatalogProductId > legacy product-type fallback > session fallback. Auto-selection always finds a top-level design (not a sub-design) since session.designId may hold a product/demographic variation from a previous generation for a different product.
  2. Loads the active selfie via getActiveSelfie(sessionId) to get demographics (gender, ageGroup)
  3. Generation cache: Checks merch_session_generation for a cached result matching (sessionId, effectiveDesignId, productType, selfieKey). On cache hit, updates the session's aiArtKey and aiArtCandidates to match the cached product (so /ai/select sees the correct candidates) and returns immediately.
  4. Calls resolveDesignConfig3Level(designId, productType, { gender, ageGroup }, fallbackChain, catalogProductId)
  5. Extracts qualityTiers, templateImageUrl, prompt, modelEndpoint from the merged config
  6. AI model resolution: Uses modelEndpoint from the design config as the primary endpoint. Falls back to the priority model from merch_ai_model table only if the config has no modelEndpoint.
  7. Passes qualityTiers to generateConsistentAIArt to control how many attempts and at what quality

Image Processing Pipeline (img-processing/)

7 modules for post-processing AI output:

ModuleFunction
flood-fill-mask.tsBFS flood fill for background detection
gaussian-blur-mask.tsSeparable Gaussian blur (OpenCV-compatible)
apply-edge-feather.tsSmoothstep edge fade
remove-background-corners.tsCorner flood-fill background removal with feathering
composite-overlay.tsSquare crop + overlay compositing
flatten-alpha.tsFlatten transparent PNGs to black background
process-ai-image.tsFull pipeline: download, BG removal, crop, overlay

FAL.AI Integration (fal/)

FilePurpose
fal-retry.tsClient config + retry with exponential backoff
ai-image-service.tsCalls fal.ai to generate AI art from selfie + template

Character Consistency (validate-character-consistency.ts)

Gemini vision likeness scoring with optional SAM segmentation. Returns overall_likeness_score (0-1).

Catalog Product Rendering

POST /api/merch/render

The current runtime render path is catalog-backed and uses catalog_renderer_config for the selected catalog product:

  1. Resolves session.aiArtKey as a media filename.
  2. For renderer-enabled products, applies background removal before compositing. If session.bgMaskKey already exists, the mask is reused; otherwise it is computed, uploaded, registered as media, and persisted on the session.
  3. Composites the cleaned art through renderProductComposite() using the product renderer config: background, art bounds, overlays, and text layers.
  4. For digital-img (rendererConfig.disabled === true), skips product compositing and uses the generated art directly.
  5. Produces a watermarked preview and a clean output, uploads both to S3, registers both as media records, and writes their filenames to the cart item (imageKey, cleanImageKey) when a cartItemId is supplied.
  6. Returns { previewFilename, cleanFilename }.

Legacy mockup and garment/plaque render endpoints still exist, but new shopper flows should use the catalog renderer.

Background mask lifecycle

Background masks are scoped to a generation. When the generation route changes aiArtKey, it clears bgMaskKey so the next render computes a fresh mask for that generation.

Order Creation

createOrGetMerchOrderFromSession (create-order.ts)

10-step idempotent pipeline:

  1. Fetch session + campaign
  2. Lifecycle validation: SOFT_CLOSE (grace expired), ENDED, EMERGENCY_CLOSE all blocked
  3. Deduplicate cart items by shop product id
  4. Validate all shop products exist and are active
  5. Price per item: use the attached shop product override price/free flag, then catalog base price as fallback
  6. Calculate totals: subtotal, shippingCost, total
  7. Idempotency check: find existing order by sessionId with matching email + totals + status
  8. Normalize address fields
  9. Create order with generated orderNumber, determine mode (tester = PREVIEW, else LIVE)
  10. Create order items via batch insert

Email

sendMerchOrderConfirmationEmail (send-email.ts)

  • Subject: Your Order Confirmation - {orderNumber} (DEMO/TEST prefix for non-live)
  • From: support@zooly.ai
  • BCC: merch-orders@zooly.ai (customer orders only)
  • HTML: items table, shipping address, order totals, back-to-merch link
  • DEMO disclaimer: "This is a demo order. No payment was taken..."
  • TEST disclaimer: "This is a test order. If you completed payment, a real charge was taken..."

sendMerchDelayedReadyEmail (send-email.ts)

Sent when AI generation takes >90s and fan provides email.

  • Subject: "Your custom merch is ready"
  • HTML: "The wait is over." headline + "View My Merch" CTA button

Campaign Lifecycle FSM

State Derivation (activation-lifecycle.ts)

isOpen = status === "LIVE" AND shutdownMode === "NONE"
isSoftClosing = shutdownMode === "SOFT_CLOSE" AND NOT graceExpired
isSoftCloseGraceExpired = shutdownMode === "SOFT_CLOSE" AND shutdownEndsAt < now()
isEmergencyClosed = shutdownMode === "EMERGENCY_CLOSE"
isEnded = status === "ENDED"
isCheckoutBlocked = isEnded OR isEmergencyClosed OR isSoftCloseGraceExpired

Valid Transitions

ActionRequired StateResult
openStoreDRAFTLIVE, isActive=true
reopenStoreENDED or EMERGENCY_CLOSELIVE, isActive=true
startSoftCloseLIVE, NONESOFT_CLOSE + 10min grace
cancelSoftCloseLIVE, SOFT_CLOSENONE
emergencyCloseLIVEEMERGENCY_CLOSE
endActivationLIVEENDED, isActive=false

SOFT_CLOSE_GRACE_MINUTES = 10

Image Verification

verifyImageWithGemini (verify-image.ts)

Gemini via Vercel AI SDK generateObject(). Checks:

Moderation signals (block on failure):

  • personDetected: Is there a clearly visible human face?
  • isBlurry: Is the image too blurry/poorly lit for merchandise?
  • isNudity: Is the image inappropriate? (includes swimwear, shirtless, bare midriff)
  • isCelebrity: Can you identify this person as a specific public figure?

Demographics (stats + prompt tuning, never block):

  • gender: female | male | not-distinctivenull when no person is visible
  • ageGroup: child | teen | 20s | 30s | 40s | eldernull when no person is visible
  • peopleCount: integer count of distinct people in the image (0 if none)

When the /verify-image or /upload/verify route is called with a sessionId in the body, the three demographic fields are persisted to the session's active merch_selfie record (via updateMerchSelfie). The merch_session.activeSelfieId is used to resolve the selfie row. Persist failures are logged but never fail the verification request.

Watermarking

applyWatermark (img-processing/watermark.ts)

Sharp + opentype.js overlay that stamps a semi-transparent diagonal "SAMPLE ONLY" label across the image. Fonts load from S3 and are cached in-process. Any failure returns the original buffer so uploads never break.

Where watermarks are applied (both LIVE and PREVIEW modes):

SurfaceSourceWatermarked?
Candidate carousel thumbnails (/create)candidate.previewKey JPGYes
Result + cart preview (/create)Cart item imageKey media filenameYes
History thumbnails (orders + incomplete sessions)aiArtPreviewKey (preview JPG from origin session's candidates)Yes
Confirm page (/confirm)Cart item cleanImageKey media filename, with legacy fallback handlingNo

The rule: everything pre-purchase is watermarked; the confirm page (post-payment) shows the clean render. Runtime render outputs are media filenames resolved through the media proxy, not raw S3 URLs.

Analytics

Session Analytics (analytics.ts)

Step order map:

studio/review/upload-verify/camera = 1
merch = 2, size = 3, loading = 4, result = 5
shipping/payment = 6, confirm = 7

Updates maxStepReached when current step exceeds previous max. Appends to stepTimeline JSON array. Tracks milestones: generationStartedAt, generationCompletedAt, checkoutStartedAt, completedAt, abandonedAt.

Utility Functions (utils.ts)

FunctionPurpose
getShippingCost(country, flatRate, intlRate)US/CA get flat rate, others get intl rate
formatCurrency(amount, currency)Currency-aware formatting using minorUnitToMajor() and getDecimalPlaces() from @zooly/util. Correctly handles zero-decimal currencies (e.g. JPY).
generateOrderNumber()ORD-{timestamp_base36}-{random_base36}

Constants (constants.ts)

ConstantValue
DEFAULT_SIZESS, M, L, XL, XXL, XXXL
DEFAULT_SHIPPING_RATE$6.95
DEFAULT_SHIPPING_INTL_RATE$15.99
DOMESTIC_SHIPPING_COUNTRIESUS, CA
DEFAULT_PAYMENT_METHODScard, apple_pay, google_pay