Filters & Seller Stores

Reusable AI filters (admin library) and the self-service seller "Store" builder that turns an uploaded photo + filters into a branded merch storefront, with lazy first-visit asset generation

What is the Filter feature?

A filter is a reusable, named AI recipe — a prompt plus an optional reference image and tags — created and curated by admins. Sellers (talent/brands) then pick filters in their self-service Store builder to turn a single uploaded photo into a set of branded merch designs, each printed on the products they choose.

There are two sides to the feature:

SideWhoWherePurpose
Filter libraryAdminsMerch Admin → Filters tabCreate / edit / delete the global filters (prompt + reference image + tags)
Store builderSellersAccount dashboard → Store pageUpload a photo, pick filters, choose products + pricing, activate a storefront

The seller storefront uses lazy asset generation: the expensive AI image + product renders are deferred from store-creation time to the first time a shopper opens the store.


Data model

Filters live in a single standalone table — they are global (not scoped to a campaign or account).

merch_filter

ColumnTypeNotes
idtext (PK)nanoid()
nametext, not nullDisplay name
descriptiontext, nullableOptional blurb
reference_image_urltext, nullablePublic S3 URL shown in the editor and used as a generation reference
tagsjsonb string[], default []Free-form tags for grouping/search (normalized: trimmed, de-duped, non-empty)
prompttext, nullableThe instruction applied during generation. Required before a filter can generate art
created_bytext, nullableEmail of the admin who created it
created_at / updated_attimestamptzcreated_at is indexed (merch_filter_created_idx)

Migration: packages/db/drizzle/0141_merch_filter.sql. Access layer: packages/db/src/access/merch/merch-filter.ts (listMerchFilters, getMerchFilterById, createMerchFilter, updateMerchFilter, deleteMerchFilter).

A seller store does not add filter columns to the merch schema. Each branded design is a regular merch_design whose default config (merch_design_config with product_type = NULL) carries a configJson that references the originating filter and tracks the lazy asset lifecycle (see Lazy asset generation).


Admin: the Filter library

Where to access

Merch Admin sidebar → Filters (/filters, admin-only). Page component: packages/merch-admin/client/src/app/pages/filters-page.tsx.

What you can do

  • Create / edit a filter: name, description, tags, prompt, and a reference image.
  • The reference-image upload button supports drag & drop (with image-type validation and visual feedback) in addition to click-to-select.
  • Delete a filter.

Business logic (validation, tag normalization) lives in packages/merch-admin/srv/src/filters.ts — router code stays thin and only handles auth, parsing, and errors.

Admin API

All under /api/merch/admin/filters, admin-only (requireAdmin), CORS-enabled. Responses use the admin shape { status: "success", data: { ... } }.

EndpointMethodBody / ParamsPurpose
/filtersGETList all filters (newest first)
/filtersPOST{ name, description?, referenceImageUrl?, tags?, prompt? }Create a filter (name required; created_by = admin email)
/filters/:idGETFetch one filter
/filters/:idPATCHpartial FilterInputUpdate (only provided fields; empty name rejected)
/filters/:idDELETEDelete

Validation failures throw FilterValidationError → HTTP 400; auth failures throw AdminAuthError.


Seller: the Store builder

The seller "Store" page (packages/offers/client/src/app/components/YourMerchPage.tsx, route /dashboard/store) is a self-service builder. There is one store per seller account.

Builder flow

  1. Upload a photo — the subject/scene the merch is built from.
  2. Pick a style — the original image is always a design (used as-is, no AI). Each filter the seller selects adds one more branded design.
  3. Choose products + pricing — from a curated catalog (currently Hoodie + T-Shirt, see below).
  4. Agree to terms & Activate — persists and activates the storefront, then shows the shareable URL.

The builder loads any existing store on mount and hydrates its state from the backend (no localStorage); an activated store's data is persisted and synced, not kept as a local draft.

The original image is always included

Whenever a photo is uploaded it is always saved as a design (filterId = null, name "Original"). A store can be published with only the original image — no filters required. In the builder the "Original" tile is shown as a permanently-selected, non-interactive thumbnail; filters are the only toggle.

Curated catalog

The seller catalog is intentionally limited to DEFAULT-HOODIE1 and DEFAULT-TSHIRT1. Catalog image keys may be bare S3 keys or absolute URLs; the endpoint only prefixes bare keys (resolveImageUrl) to avoid double-prefixing.

Defaults

New seller stores are created commercial + isEvergreen and default to the Design First shop flow (shopFlow: "design_first") — the storefront leads with the design picker, not the product grid.

Seller API

Authenticated (non-admin) endpoints under /api/merch/seller, built on the @zooly/util-srv route() helper (auth: "user").

EndpointMethodPurpose
/seller/filtersGETList the global filters for the builder
/seller/catalogGETList the curated catalog products (Hoodie, T-Shirt) with resolved image URLs
/seller/storeGETLoad the seller's single store (or null) to hydrate the builder
/seller/storePOSTCreate or update (upsert) the store: campaign + products + designs (maxDuration = 300)
/seller/store/generatePOST{ uploadedImageUrl, filterId } → one-off branded preview (legacy/eager path; lazy stores no longer call this)

Client wrappers live in packages/offers/client/src/lib/appApi.ts (fetchMerchFilters, fetchMerchCatalog, fetchMerchStore, saveMerchStore, generateBrandedMerchItem).

POST /seller/store design entries accept { filterId: string | null, name, imageUrl? }. filterId = null is the original; imageUrl is optional because lazy stores persist designs without a pre-generated image.


Branded art generation

A filter design's art is produced by generateBrandedItem (packages/merch/srv/src/seller-store.ts):

  • Inputs: the seller's uploaded image and the filter's referenceImageUrl, both passed as plain references.
  • Instruction: the filter's prompt (a missing prompt is a hard error).
  • Model: gpt-image-2 (gateway openai/gpt-image-2) followed by BRIA RMBG 2.0 background removal, quality low.

This uses a dedicated generateBrandedArt path (packages/merch/img-gen/src/ai-image-service.ts) — not the selfie pipeline — so there's no background flattening / selfie filtering. The result is uploaded to merch/seller-store/… on S3.

The original design needs no AI: it is the uploaded image itself, simply rendered onto the product mockups (same compositing pipeline as the admin "Render" button, renderProductPreviewForArt).


Lazy asset generation

To keep store activation fast and avoid paying for AI on stores nobody visits, the heavy work — the AI image (for filter designs) and the per-product mockup renders — is deferred to the first storefront visit.

Asset lifecycle (assetStatus)

Stored in each design's default configJson (no migration needed):

StatusMeaning
pendingInputs saved, nothing generated yet (lazy filter designs)
generatingA materializer has atomically claimed it (generatingStartedAt set)
readydisplayImageUrl + renderedProductPreviews are populated
errorLast materialize attempt failed (retryable)

Legacy/eager designs omit assetStatus and are treated as ready.

What's eager vs. lazy at save time

  • Original design → always materialized at save (no AI, just render onto products) so its tile is ready immediately.
  • Filter designs → saved pending; materialized on first visit.

materializeDesignAssets(designId)

The single, idempotent unit of work:

  1. Resolve the base art — original ⇒ the uploaded image; filter ⇒ generateBrandedItem(...).
  2. Render a per-product composite for each attached catalog product → renderedProductPreviews.
  3. Persist displayImageUrl + renderedProductPreviews and flip assetStatus = ready (or error on failure).

First-visit materializer + concurrency

  • POST /api/merch/store/[slug]/ensure-assets triggers materialization; GET returns a read-only status snapshot. Public (anonymous shoppers), maxDuration = 300.
  • Work is claimed one design at a time via claimNextPendingDesignForGeneration — an atomic UPDATE … FOR UPDATE SKIP LOCKED that flips pending/error/stale-generatinggenerating. This makes concurrent first-visitors safe (no double-generation) and lets stale claims (older than 5 min) be reclaimed.

Storefront skeletons

The campaign layout mounts useEnsureStoreAssets(), which POSTs ensure-assets, polls until pending === 0, then silently refetches the campaign so freshly generated images appear. On the Choose Your Design page, designs that aren't ready show a loading spinner and cannot be selected (single-design auto-skip also waits for ready). Because selection is blocked until ready, the chosen design is always ready by the time the shopper reaches the product picker. assetStatus is surfaced to the client through campaign-transforms.ts.

Checkout invariant

replaceCartItemsForSession (cart-service.ts) calls the centralized ensureDesignReadyForCheckout(designId) for every cart item's design: an unready design is materialized inline, and if it still can't be made ready the cart write is blocked. This guarantees no unfulfillable item is ever committed. No-op for eager/legacy designs.

Straggler backfill (cron)

For stores that are never visited (or whose materialization stalled), GET /api/merch/cron/store-asset-backfill (Bearer CRON_SECRET, every 10 min in vercel.json) finds campaigns with pending designs (listCampaignIdsWithPendingDesigns) and drains their claims via backfillPendingSellerStoreAssets(). Concurrency-safe alongside live first-visit traffic.


Architecture

Database
  packages/db/src/schema/merchFilterTable.ts            merch_filter table
  packages/db/src/access/merch/merch-filter.ts          filter CRUD
  packages/db/src/access/merch/merch-design.ts          claimNextPendingDesignForGeneration,
                                                         listCampaignIdsWithPendingDesigns
  packages/db/drizzle/0141_merch_filter.sql             migration

Admin filter library
  packages/merch-admin/srv/src/filters.ts               validation + business logic
  apps/zooly-app/app/api/merch/admin/filters/…          admin CRUD endpoints
  packages/merch-admin/client/src/app/pages/filters-page.tsx   admin UI

Seller store + lazy generation
  packages/merch/srv/src/seller-store.ts                generateBrandedItem, saveSellerStore,
                                                         getSellerStore, materializeDesignAssets,
                                                         ensureStoreAssets, getStoreAssetStatus,
                                                         ensureDesignReadyForCheckout,
                                                         backfillPendingSellerStoreAssets
  packages/merch/img-gen/src/ai-image-service.ts        generateBrandedArt (branded, no selfie steps)
  packages/merch/srv/src/campaign-transforms.ts         surfaces assetStatus to the storefront
  packages/merch/srv/src/cart-service.ts                checkout invariant gate
  apps/zooly-app/app/api/merch/seller/…                 filters / catalog / store / generate
  apps/zooly-app/app/api/merch/store/[slug]/ensure-assets/route.ts   first-visit materializer
  apps/zooly-app/app/api/merch/cron/store-asset-backfill/route.ts    straggler cron

Storefront (shopper)
  packages/merch/client/src/hooks/use-ensure-store-assets.ts        trigger + poll + refresh
  packages/merch/client/src/app/campaign-layout.tsx                 mounts the hook
  packages/merch/client/src/app/pages/design-selection-page.tsx     skeletons + ready gating

Seller builder (talent dashboard)
  packages/offers/client/src/app/components/YourMerchPage.tsx
  packages/offers/client/src/lib/appApi.ts

Notes & gotchas

  • One store per seller accountsaveSellerStore upserts; it never creates a second campaign for the same account.
  • Filters are global — there is no per-account or per-campaign filter scoping.
  • A filter needs a prompt before it can generate; the reference image is optional but recommended (it's passed as a generation reference).
  • saveSellerStore is lazy by default (lazy: true) — it persists filter designs as pending and skips AI/render; only the original is materialized at save.
  • Polling uses POST (ensure-assets) so each storefront poll makes progress; calls are awaited sequentially to avoid overlap, and claims are atomic.