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 and user-driven regeneration:

  1. Generate a single low-quality image via generateAIArt() (FAL.AI)
  2. Post-process with downloadAndProcessAIImage(), upload to S3, then score with validateCharacterConsistency() (Gemini vision)
  3. If overall_likeness_score ≥ threshold (default 60), return immediately
  4. Otherwise retry sequentially up to MAX_GENERATION_ATTEMPTS (default 2), always keeping the best result
  5. Return the best candidate as aiArtKey and all attempts as candidates
  6. New candidates are prepended to any existing ones from prior generations in the session's aiArtCandidates
  7. The user can click "Regenerate" to request another attempt; 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.

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).

Mockup Generation

generateMockup (generate-mockup.ts)

Mode selected by MERCH_MOCKUP_MODE env var:

Composite mode (default):

  1. apparelPrintBlend(): Gaussian blur, edge-feather mask, brightness/saturation adjustment
  2. compositeOnGarment(): Template from garment templates, print area ratios (tshirt: 50% width centered, hoodie: 50% width centered slightly lower)

AI try-on mode:

  1. Composite mockup as base
  2. Upload to S3
  3. Random synthetic human from S3 listing
  4. FAL AI virtual try-on
  5. Upload result to S3

Plaque Rendering

renderPlaqueComposite (render-plaque.ts)

5-layer Sharp composition:

  1. Base plaque background from campaign config
  2. AI vinyl disc: center-crop to square, resize to disc diameter, circular mask
  3. Spindle hole: black circle at center (radius = discRadius x 0.04)
  4. Text overlay: opentype.js fonts from S3 (cached in memory), SVG paths for fanName + message
  5. Final composite: Sharp .composite() all layers to PNG

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 productId
  4. Validate all products exist and are active
  5. Price per item: plaque uses resolvePlaquePricing(), non-plaque uses product.price
  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 2.5 Flash via Vercel AI SDK generateObject(). Checks:

  • 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?

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)Intl.NumberFormat formatting
generateOrderNumber()ORD-{timestamp_base36}-{random_base36}
resolvePlaquePricing(config, sizeLabel)Standard/deluxe pricing lookup with fallbacks

Constants (constants.ts)

ConstantValue
DEFAULT_SIZESS, M, L, XL, XXL, XXXL
DEFAULT_SHIPPING_RATE$6.95
DEFAULT_SHIPPING_INTL_RATE$15.99
Default product pricestshirt: $69, hoodie: $109, plaque: $39
DOMESTIC_SHIPPING_COUNTRIESUS, CA
DEFAULT_PAYMENT_METHODScard, apple_pay, google_pay