Skip to main content

Product Image Sync

Shopify -> ProductImage sync, Jewelry title-lookup path, WorkOrder image display, enablement flags.

Deep-dive doc split out of .claude/rules/architecture.md (which is now an index). Append new findings about this area here, not to the index.

Product Image Sync (product domain)​

Pulls Shopify product media into the local ProductImage table and orders them via AI classification. There is no Shopify webhook — sync is pull-based, keyed by a canonical SKU built from the style number plus a hardcoded color/size.

  • Canonical SKU: <styleNumber> + color GR0018 (Sage) + L-size code 004. The color is hardcoded as the standard internal reference colorway; size L is assumed to exist for all styles. The L-size code is looked up from prisma/seed/data/sizes.json (size === 'L' → sizeCode '004') — not from the DB. The color/size literals live in src/constants/productImage.ts as the single source of truth (PRODUCT_IMAGE_DEFAULT_COLOR_CODE = 'GR0018', PRODUCT_IMAGE_FALLBACK_COLOR_CODE = 'BK0001' — Black, the Sage fallback colorway, PRODUCT_IMAGE_DEFAULT_SIZE = 'L'; extracted INFRA-648) — do not duplicate them at call sites (seed-styles.ts and product.service.ts both import from here).
  • Sage → Black fallback (INFRA-648): syncImages resolves the Shopify variant via _resolveStyleVariant(sku) before persisting. If the requested (Sage) sku query succeeds but matches no variant, it retries once with the Black (BK0001) colorway. Only a resolved-but-empty result triggers the fallback — transient Shopify failures (5xx/timeout) throw out of the retry client and never reach the fallback branch, so an outage is never misrecorded as a color fallback. Non-Sage skus never fall back. The storage key is the resolved variant's real Shopify sku (_resolveStyleVariant returns { variant, resolvedSku }) — for a Black-fallback sync that is the BK0001 key, not the requested Sage key. Rows previously stored under the Sage key are intentionally left in place: dual-key storage is accepted because both keys' image sets come from the same Shopify product's media, and every read path still resolves (exact match hits whichever key a reader built; the non-Jewelry style-number-prefix fallback in getImagesBySku/getSkusImages covers the rest — see "Canonical-Color Fallback" below for the full analysis). Jewelry paths (syncJewelryStyleColorImages/syncImagesForJewelrySku) do not use syncImages and have no fallback. Since all four trigger points funnel through syncImages, the fallback applies to all of them automatically.
  • Classification reset on re-sync (INFRA-648): ProductImageModel.upsertMany's ON CONFLICT ("sku","position") DO UPDATE now also resets classification/classifiedAt/classifiedBy to NULL. A re-sync writes images back in raw Shopify order, which invalidates any prior classification (which may have reordered positions) — without the reset, a row could carry a new imageUrl next to the previous image's classification indefinitely when the classify pass fails. After this fix, INFRA-585-style diagnoses can trust classification without cross-checking classifiedAt vs syncedAt.
  • Core service: ProductService.syncImages(sku, prismaInstance?) (src/services/product/product.service.ts:95) — resolves the Shopify variant via _resolveStyleVariant(sku) (see Sage → Black fallback above), filters media.nodes to MediaContentType.Image, dedupes by URL, then in a $transaction (20s timeout) upsertManys the images (position-ordered) and deleteManys any rows at position >= newCount — all keyed by resolvedSku, the variant's real Shopify sku. Returns { images, persistedSku }; persistedSku differs from the requested sku exactly when the Black fallback resolved. Throws makeBadRequestError if no variant matches the SKU. The optional prismaInstance param exists so the standalone seed script's new PrismaClient() can be threaded through.
  • AI ordering: classifyProductImagePosBySku(sku, rules?) (src/utils/classifyProductImagePos.ts) uses OpenAI to classify each image front/back/other and rewrites position (front→0, back→1, rest after). Called as a separate step after syncImages.
  • syncStyleImagesBestEffort return contract (INFRA-648): returns { sku: string | null; error?: any; count: number }, not the old string | null. sku is the persisted sku — the canonical Sage sku normally, the Black (BK0001) sku when the fallback resolved, or null if buildStyleImageSku fails. error is set on sync or classify failure (classification failure does not fail the sync — sku is still returned and captureSyncProductImageFailed is not called), and count is the number of images persisted (0 on any failure). Classification runs under the persisted sku (classifyProductImagePosBySku(persistedSku)) since that is the key the rows were written with. resyncProductImagesActionHandler reads error to pick the notice type and count to stamp record.params.imagesCount. Callers that only void the result (newActionHandler, webhooks.service.ts) are unaffected by the richer return.
  • Trigger points (4 call sites for syncImages):
    1. REST API — POST /api/product/v1/syncImages/:sku (apiKeyAuth, controller src/controllers/product/product.controller.ts:23). Validates, calls syncImages, then setImmediate(classifyProductImagePosBySku). On error calls captureSyncProductImageFailed(req, err) and rethrows. This is the N8N-driven path.
    2. AdminJS New-Style form — newActionHandler (src/routers/admin/resources/style/handlers.ts:75-77) runs setImmediate(() => void ProductService.syncStyleImagesBestEffort(styleUpdateParams.styleNumber)) after creating the style. This is fire-and-forget with all failures swallowed — syncStyleImagesBestEffort is best-effort and non-throwing (failures are captured to Sentry inside it and never surface to the operator), so a transient failure leaves the style permanently imageless and invisible until someone runs the resyncProductImages record action or the --missing-only seed.
    3. Manual seed script — npm run db:seed:productimage → prisma/seed/run-productimage.ts (loads all non-deleted styles) → seedProductImage(prisma, styleNumbers) (prisma/seed/seed-productimage.ts). Per-style try/catch builds successSkus/failedSkus, prints a report, then runs the classify loop over successes. This is the manual recovery path operators run when images are missing. Supports --missing-only (-m, INFRA-648): filters to styles with zero ProductImage rows under their style-number prefix (filterStylesWithMissingImages) instead of re-syncing everything. Caveat: the predicate is per-style-prefix, so a Jewelry style with some (but not all) colorways synced is not caught — partial Jewelry gaps are out of scope. filterStylesWithMissingImages uses findMany({ select: { sku: true }, distinct: ['sku'] }), which required enabling the nativeDistinct Prisma preview feature in prisma/schema.prisma — keep that flag if the distinct query shape is preserved.
    4. AdminJS Edit-Style — none. editActionHandler never re-syncs images.
    5. AdminJS Style resyncProductImages record action (INFRA-648) — src/routers/admin/resources/style/style.ts + handlers.ts. actionType: 'record', adminRoleAuth, component: false; calls ProductService.syncStyleImagesBestEffort(styleNumber) and reports the outcome via an AdminJS notice (success with the sku, or an error pointing at Sentry), so a one-off re-sync needs no API key/curl and a silent failure is surfaced to the operator immediately.
  • Style list imagesCount virtual column (INFRA-648): listActionHandler (src/routers/admin/resources/style/listActionHandler.ts:57-74) enriches each listed style with an imagesCount virtual column — a per-style ProductImage row count. Because ProductImage has no FK to Style, the link is the sku prefix (sku.substring(0, styleNumber.length) === styleNumber); the count is fetched with a single ProductImageModel.groupByStyleNumbers(styleNumbers) call (src/models/productImage/productImage.model.ts:143-157, groupBy on sku with _count: { _all: true } and an OR of sku.startsWith per styleNumber), then aggregated back in JS by matching each group's sku prefix to its styleNumber. Declared as a type: 'number' virtual property in style.ts:155 and appended to listProperties. This gives operators an at-a-glance "has images?" signal on the Style list, complementing the resyncProductImages repair action.
  • Operator runbook (zero-image styles): a style with zero ProductImage rows is structurally invisible in the ProductImage list (it can only show rows that exist), but is now visible via the Style list's imagesCount column. Repair path: open the Style in AdminJS → run resyncProductImages → the notice shows success/failure (failures point to Sentry module=productImage, processName=syncProductImage). The ProductImage list/filter now exposes classification/classifiedAt/classifiedBy (INFRA-648) so classification state can be inspected without opening each record. Bulk recovery: npm run db:seed:productimage -- --missing-only.
  • Airtable/webhook trigger (previously a gap, now closed): WebhooksService.syncAirtableStyle (src/services/webhooks/webhooks.service.ts:73-79) now runs setImmediate(() => void ProductService.syncStyleImagesBestEffort(body.styleNumber)) after the style upsert commits — fire-and-forget, on both create and update (the update path doubles as an automatic retry for styles whose earlier sync failed). This replaced the pre-INFRA-542 state where the Airtable path had no image trigger at all and styles arriving via Airtable (the normal inbound path) silently got no images until someone ran the seed script.
  • Sentry capture: captureSyncProductImageFailed(req, error) (src/utils/sentryUtils.ts:396) reads only req.params?.sku — so it was usable only from the controller, not background/webhook callers (tags: module=productImage, processName=syncProductImage, eventType=failed). Constants live in src/constants/sentryTags.ts (SENTRY_SYNC_PRODUCT_IMAGE_EVENT_TYPE.failed). captureProductImagesClassifyDone logs classify outcomes.
  • Specs: product.service.spec.ts exists (mocks shopifyClient, prismaClient.$transaction, productImage model; uses test()); webhooks.service.spec.ts exists; listActionHandler.spec.ts covers the imagesCount enrichment. The Style resyncProductImagesActionHandler and groupByStyleNumbers have no direct spec (the latter is a thin handleDbErr wrapper over Prisma groupBy).

Jewelry-Category Image Sync Gotchas (INFRA-590 investigation)​

The Dress-only canonical-SKU sync (above) cannot work for Jewelry styles at all — this was verified against the live birdy-grey-test-store.myshopify.com Shopify test store via the claude.ai Shopify MCP connector and cross-checked against the local DB, not assumed:

  • No usable SKU exists on Jewelry variants. Simple Size-only families (Ring Size, Necklace Size, Bracelet Size — no Carat) have sku: null on every variant. Size+Carat families (Ring+Carat, Necklace+Carat, Ring+Carat Two Stone, Earring Carat) do have SKUs, but they're sequential placeholders like TEST-FJ-0184 — unrelated to MES's styleNumber+colorCode+sizeCode scheme. Neither family is reachable by sku: query, regardless of which color/size literal is chosen.
  • Color is a fixed product-level attribute for Jewelry, not a variant axis. Unlike Dress, a Jewelry style with multiple gemstone colorways (e.g. "East West Oval Cut 14K Gold Fill Ring") is split into multiple separate Shopify products — one per colorway (... - Green Amethyst, ... - White Topaz, ... - Blue Topaz), each with its own single image. Diamond/Carat-family products have a generic color = "Diamond" and are 1:1 with one Shopify product (no colorway split).
  • Each Jewelry product has exactly 1 image total (not per-variant) — this is what makes "show 1 image on the Work Order for Jewelry" both correct and necessary; showing a 2nd (front/back-style) image slot doesn't apply to Jewelry at all.
  • The reliable link back to MES is the product title, not SKU. Verified formula, checked against all 46 Jewelry-scoped styles in the local DB against live Shopify data: Style.styleNameEn == <Shopify attr.style metafield> + " " + <attr.fabric> + " " + <attr.type> (e.g. "Bezel Diamond" + " " + "14K Solid Yellow Gold" + " " + "Necklace" = "Bezel Diamond 14K Solid Yellow Gold Necklace", an exact Style.styleNameEn match). For the colorway-split families, the Shopify product title further appends " - " + <attr.color>, and MES's Color.colorNameEn already has matching rows (Green Amethyst, White Topaz, Blue Topaz, Diamond, etc.) — so the full lookup is: try title:"<styleNameEn>" first (works for Diamond/Carat family), fall back to title:"<styleNameEn> - <colorNameEn>" using the Work Order's actual colorId (works for colorway-split families). No Shopify product ID/handle is captured anywhere in MES's Airtable→Style pipeline (AirtableStyleWebhookBodySchema only carries styleNumber/styleName/sizeRangeRecordId), so title reconstruction is the only available link.
  • Detecting "is this Style Jewelry" must be done by SizeRange name, not sizeRangeId or Airtable Record ID. Confirmed empirically: the same-named Jewelry SizeRanges (13. NECKLACE SIZE, 14. BRACELET SIZE, 15. RING SIZE, 17. RING + CARAT SIZE, 18. NECKLACE + CARAT SIZE, 19. RING + CARAT SIZE TWO STONE, 20. EARRING CARAT SIZE) have different sizeRangeId and airtableRecordId values in the local dev DB vs. staging (different Airtable bases per environment, same shape as the documented Test-Base-to-Test-Base-V2 migration risk under Operational Scripts). SizeRange.name is the only field confirmed stable across environments — mirrors the existing codebase precedent of matching Milly/Jewelry factories by factoryNameEn rather than factoryId/factoryCode.
  • Two independent WorkOrder-creation code paths both need any Jewelry-sync hook, since either can be the entry point: (1) the singular POST /workorder/v1/create → workorderService.create(req) (src/services/workorder/workorder.service.ts); and (2) the bulk POST /shipment/.../create → shipment.controller.ts's create() → workorderService.batchCreateByTransaction (inside a prisma.$transaction, so any external Shopify call must happen after the transaction commits). Both are now wired (see Implementation below).
  • Cache-key gotcha on StyleModel.findStyleUnique (src/models/style/style.model.ts): the in-memory cache key is `style:${JSON.stringify(params.where)}` — keyed only on where, not on include/select. Multiple call sites (skuUtils.ts#parseSkuInfo, style.service.ts, webhooks.service.ts) call findStyleUnique({ where: { styleNumber } }) with no include. Adding include: { sizeRange: true } to one caller risks a stale/narrower cached object (populated by a different caller with no include) silently missing the relation, or vice versa. Do not add a new include shape to an existing findStyleUnique call site that shares a where shape with other callers — do a separate, differently-keyed lookup instead (e.g. via SizeRangeModel, a different cache namespace) when a caller needs an additional relation the existing callers don't. This is why parseSkuInfo (singular) resolves the SizeRange name via its own SizeRangeModel.findSizeRangeUnique({ sizeRangeId }) call rather than adding include: { sizeRange: true } to its existing findStyleUnique call.
  • MES already carries everything needed to resolve Jewelry colorways — Color table has real rows for gemstone names (Green Amethyst GR0040, White Topaz WT0016, Blue Topaz BL0048, Diamond WT0015) alongside metal-tone colors (Gold NT0009, White Gold NT0024, Yellow Gold NT0025, Rose Gold PK0026) — no new reference data needed, only new code paths to use it.

Implementation (sync-side, INFRA-590 first half — the Work Order display-side "show 1 image for Jewelry" is a separate, not-yet-done follow-up):

  • src/constants/jewelrySizeRanges.ts — JEWELRY_SIZE_RANGE_NAMES (Set of the 7 names) + isJewelrySizeRangeName(name). Single source of truth for Jewelry detection, matched by name per the gotcha above.
  • SkuInfo.style.sizeRangeName: string | null (src/utils/skuUtils.ts) — added to both parseSkuInfo (singular; resolved via a dedicated SizeRangeModel.findSizeRangeUnique call, run in parallel with the existing size lookup) and parseSkuInfos (batch; populated from the sizeRangeId2NameMap it already built internally for error messages — no new query needed there).
  • src/clients/shopifyClient/shopifyClient.ts — shopifyGetProductMediaByTitle(title): GraphQL products(first:1, query: 'title:"<title>"'), returns the product's media directly (no variant match needed, unlike the SKU-based query).
  • src/services/product/product.service.ts — syncImages's dedupe+upsert/delete logic is extracted into a shared persistProductImages(sku, medias, meta, prismaInstance?) helper, reused by both the SKU-based (Dress/Scarf) and title-based (Jewelry) paths. New: buildJewelrySku(styleNumber, colorCode) → `${styleNumber}${colorCode}000` (synthetic ProductImage.sku cache key — Jewelry has no real Shopify SKU, and images don't vary by size, so '000' is a fixed placeholder keeping the same 5+6+3 shape the rest of the codebase assumes); syncJewelryStyleColorImages(sku, styleNameEn, colorNameEn, prismaInstance?) (title lookup with the plain→color-suffixed fallback); triggerJewelryImageSyncIfApplicable({ styleNumber, styleNameEn, sizeRangeName, colorCode, colorNameEn }) (best-effort, Sentry-captured, no-ops unless isJewelrySizeRangeName(sizeRangeName) — the single entry point both creation paths call). Unlike syncStyleImagesBestEffort, this does not run classifyProductImagePosBySku — Jewelry has exactly one image, so there's no front/back to classify.
  • Hooked into workorder.service.ts#create (singular path, fires after workOrderModel.create succeeds) and shipment.controller.ts#create (batch path, fires in the existing post-transaction "don't block the response" section, deduped by `${styleId}-${colorId}` across the whole skuNumber2SkuInfoMap so a repeated SKU across quantities/shipments in one batch doesn't re-sync the same Shopify product multiple times).

Implementation (display-side, INFRA-590 second half — "show 1 image on the Work Order for Jewelry"):

  • WorkOrderData.isJewelry: boolean (schemas/workorder/workorder.types.d.ts + the batch WorkOrderBatchInfosByPoIdResponseSchema in schemas/workorder/index.ts; the singular WorkOrderResponseSchema uses .passthrough() so it didn't need a change). Computed by a shared _resolveJewelrySizeRangeIds() helper in workorder.service.ts — one SizeRangeService.findMany({ where: { name: { in: [...JEWELRY_SIZE_RANGE_NAMES] } } }) call per request, returning a Set<sizeRangeId> — called once in makeWorkOrderData (single WO) and once in _makeWorkOrderDatas (batch, computed outside the per-WO .map() so it's not re-queried per row). Threaded through _createWorkOrderDataByRecords's records param alongside enableMeasurements/enableConstructionNotes.
  • src/components/config.ts#makeSkuForGetImages(style, colorCode?, isJewelry?) — gained two optional params. When isJewelry && colorCode, builds `${style}${colorCode}000` (must exactly match the backend's ProductService.buildJewelrySku synthetic key, including the '000' size placeholder — duplicated as jewelrySizeCodeForImages in this file, mirroring the existing colorCodeForImages/sizeCodeForImages Dress-default duplication between frontend and backend). Falls back to the Dress default in every other case, so this is fully backward-compatible when called with just style.
  • WorkOrderTemplate/utils.tsx#useWorkOrder — getSkuImages now takes (sku, colorCode, isJewelry), reading the real color.colorCode/isJewelry off the fetched WorkOrderData, and requests fetchSkuImages(sku, isJewelry ? 1 : 2).
  • WorkOrderMaterial.tsx — the materials table's image column was already built on a rowSpan trick (halfRows = Math.ceil(totalRows/2); first image cell spans rows [0, halfRows), second spans [halfRows, totalRows)). Jewelry reuses the exact same mechanism: halfRows = isJewelry ? totalRows : Math.ceil(totalRows/2) makes the first image cell span the entire table, and the second cell's render condition (isSecondImageRow = !isJewelry && rowIndex === halfRows) is gated off entirely — no new layout code, just different rowSpan math.
  • pagesUtils.ts#workOrderDatasFetchImages (the batch/multi-WO print & export flow) previously deduped fetch keys by style only (item.sku.substring(0,5)), which happened to be correct for Dress only because makeSkuForGetImages used to ignore color entirely. Now keys by a new makeSkuForGetImagesForWorkOrder(workOrderData) helper (style + the WO's own color.colorCode + isJewelry) — for Dress this still collapses to one shared key per style (since makeSkuForGetImages ignores colorCode unless isJewelry is true, so behavior is unchanged), but for Jewelry it correctly fetches a distinct image per (style, color) pair. first count checks every WO in the batch (firstData.isJewelry && workOrderDatas.every((wo) => wo.isJewelry)), not just firstData alone — falls back to 2 unless the whole batch agrees it's Jewelry (see the "Mixed batch" defensive guard test in pagesUtils.spec.ts).

Enablement-flag resolution and the WorkOrderMainDisplay render-gating convention. enableMeasurements/enableConstructionNotes/isJewelry are the three booleans on WorkOrderData that WorkOrderMainDisplay.tsx (src/components/pages/WorkOrderTemplate/) uses to decide what to render — {data.enableMeasurements && <WorkOrderMeasurement .../>}, {data.enableConstructionNotes && (<><WorkOrderConstructionNotes/><WorkOrderMaterial isJewelry={data.isJewelry} .../></>)}. All three are resolved server-side only — the frontend never re-derives them from a raw feature-flag or category check, it just branches on the pre-resolved fields. The resolution machinery, in src/services/workorder/workorder.service.ts:

  • _resolveEnablementFlags({ millyEnabled, factoryCode, poCategory }) (module-private, ~line 1359) is a pure function: enableMeasurements/enableConstructionNotes default to true/true, and are overridden by an if/else-if chain of per-factory sibling branches: (1) millyEnabled && factoryCode === env.FACTORY_CODE_MILLY → Scarf gets measurements-only, anything else gets both off; (2) poCategory === PO_CATEGORIES.SCARF && factoryCode === env.FACTORY_CODE_AOLONG → measurements-only, matching Milly Scarf (INFRA-613; re-keyed from the retired env.FACTORY_CODE_AOLONG_SCARF onto the Aolong factory code by INFRA-633, which folded Aolong Scarf into the Aolong Factory row). The second branch is deliberately not gated behind milly_enabled — that flag is Milly-specific, so no new feature flag was warranted. Note this branch is now live for real Aolong traffic rather than inert: it is the poCategory check alone that keeps Aolong dress work orders on the both-enabled default, so the two conditions are load-bearing together in a way they were not when the factory code was scarf-exclusive. Adding a third factory with the same rendering should follow the same shape (a new else if) rather than generalizing the Milly branch — keeping Milly's behavior provably untouched has been the priority each time. Not unit-tested directly (not exported) — covered indirectly via makeWorkOrderData/makeBatchInfosByPoId assertions in workorder.service.spec.ts.
  • _resolveJewelrySizeRangeIds() (~line 1361) resolves isJewelry inputs — see the Jewelry gotchas above.
  • _createWorkOrderDataByRecords({ records: { wO, pO, style, color, size, shipment, enableMeasurements, enableConstructionNotes, isJewelry }, lang }) (~line 1154) is the single shared constructor for a WorkOrderData object — both makeWorkOrderData (single WO) and _makeWorkOrderDatas (batch, called once per WO inside its .map()) build their records input and delegate here. Any new field belongs on this shared records param + return object, not duplicated at each call site.
  • Call-site shape: both makeWorkOrderData and _makeWorkOrderDatas compute millyEnabled/jewelrySizeRangeIds first, call _resolveEnablementFlags (batch: once per request, not per-WO — enableMeasurements/enableConstructionNotes are treated as PO-wide since Milly's gate only depends on factoryCode+poCategory, both singular per PO), then compute isJewelry per-style (batch: per-WO, inside the .map(), since a style's sizeRangeId varies per WO) before calling _createWorkOrderDataByRecords. Any new resolved field whose truth depends on isJewelry (like a jewelry-specific enablement override) therefore cannot piggyback on the once-per-request _resolveEnablementFlags call in the batch path — it needs its own per-WO override step inside the map, applied after the shared once-per-request flags are computed, so a mixed-category batch's non-Jewelry WOs keep their normal Milly-derived flags.
  • Two independent image-fetch trigger points, both gated on enableConstructionNotes or enableJewelryImages (any future flag that hides Construction Notes for a subset of WOs must update both, or images silently stop loading for that subset):
    1. WorkOrderTemplate/utils.tsx#useWorkOrder's getSkuImages call, gated by if (responseWorkOrderData.enableConstructionNotes || responseWorkOrderData.enableJewelryImages). Feeds WorkOrderTemplate.tsx and Dashboard/WorkOrderModal.tsx (both call the useWorkOrder hook directly for a single WO).
    2. pagesUtils.ts#workOrderDatasFetchImages's shouldFetchImages = firstData.enableConstructionNotes || workOrderDatas.some((wo) => wo.enableJewelryImages). The enableConstructionNotes half reads only firstData (safe — it's PO-wide), but the enableJewelryImages half uses .some() across the whole batch (NOT firstData alone) since it depends on per-WO isJewelry and a mixed batch's Jewelry WOs would otherwise get no images if firstData itself isn't Jewelry. Feeds WorkOrderList.tsx and BatchDownloadWorkorder.tsx (both fetch a WorkOrderData[] upfront and call workOrderDatasFetchImages once for the whole batch, not through the hook).
  • Work Order Template subcomponent pattern: WorkOrderConstructionNotes.tsx and single-row sections follow a single-<table>-single-<tr> shape with the section label as the first <td> (bold, translated via useTranslation().translateComponent('<Component>.<key>')) and content in the following <td>(s) — see WorkOrderConstructionNotes.tsx for the minimal example. WorkOrderMeasurement/WorkOrderMaterial are the multi-row variants of the same translation convention. None of these subcomponents are registered in componentLoader.ts — they're plain React components imported by relative path directly into WorkOrderMainDisplay.tsx (consistent with the project-wide rule that src/components/** runtime imports must be relative, only AdminJS-mounted components go through componentLoader.ts).
  • Locale files are flat, not directory-per-locale: src/locales/en.json and src/locales/zh_CN.json (not src/locales/en/*.json as one might expect) — both files mirror the same key structure at the same line numbers, so a new component's translation keys should be inserted at the equivalent alphabetical position in both files together (e.g. WorkOrderConstructionNotes/WorkOrderHeader/WorkOrderImages/WorkOrderMeasurement/WorkOrderTemplate all sit at identical line numbers in both files today).

Implementation (INFRA-593 — the Work Order Template display-side follow-up to INFRA-590/591, "Images section behind jewelry_enabled"):

  • New feature flag FEATURE_FLAG_KEYS.jewelryEnabled = 'jewelry_enabled' (src/constants/featureFlagKeys.ts), read via isFeatureEnabled alongside millyEnabled in both makeWorkOrderData and _makeWorkOrderDatas.
  • New WorkOrderData.enableJewelryImages: boolean field — true only when jewelry_enabled is on and isJewelry is true (never derived from PurchaseOrder.category, matching the existing isJewelry precedent, so a Dress-category style is unaffected by the flag regardless of state). Added to workorder.types.d.ts and WorkOrderBatchInfosByPoIdResponseSchema (the singular WorkOrderResponseSchema needs no change — .passthrough()).
  • New module-private _applyJewelryImagesOverride(flags, { jewelryEnabled, isJewelry }) in workorder.service.ts, deliberately kept separate from _resolveEnablementFlags rather than folding jewelry inputs into it: _resolveEnablementFlags (Milly-only) is still called once-per-request in the batch path, while _applyJewelryImagesOverride is layered on top — once-per-request in the single-WO path (isJewelry known upfront), but once per WO inside the batch .map() (since isJewelry varies per style within a batch). It returns { enableMeasurements, enableConstructionNotes, enableJewelryImages }, forcing the first two to false whenever enableJewelryImages is true. This two-function split is the reason a mixed Dress+Jewelry batch works correctly: the batch-wide data-loading gate (whether to bother querying GarmentMeasurement/MaterialConstruction/Measurement/Material at all) stays keyed off the Milly-only result, while only the per-WO display flags get the jewelry override — so a mixed batch's non-Jewelry WOs still get their materials/measurements data loaded even though some Jewelry WOs in the same batch null theirs out.
  • New WorkOrderImages.tsx component (src/components/pages/WorkOrderTemplate/) — single-row-table shape per the subcomponent pattern above: label cell ("Images", WorkOrderImages.images key) | ${styleName} - ${colorName} text (via getLocalizedValue, same format INFRA-590 uses for the Shopify title lookup) | the synced photo or a WorkOrderImages.noImage fallback. Wired into WorkOrderMainDisplay.tsx as a third gated section: {data.enableJewelryImages && <WorkOrderImages .../>}, alongside (not replacing) the existing enableMeasurements/enableConstructionNotes branches — no extra hiding logic needed there since the backend already zeroes those two out whenever enableJewelryImages is true.
  • Both image-fetch trigger points (above) updated in the same change to also check enableJewelryImages, since the ticket that introduced this flag only initially called out the useWorkOrder one — the batch-print pagesUtils.ts one has the identical structural dependency on enableConstructionNotes and would have silently stopped fetching images for Jewelry-flagged batches otherwise.

Vitest gotchas hit while adding test coverage for the above (generic to this codebase's test setup, not Jewelry-specific — worth knowing before writing any new spec):

  • Running many spec files concurrently in this sandboxed dev environment can produce spurious test timeouts unrelated to any code change — e.g. useWorkOrder's image-fetch tests (5s testTimeout) intermittently failed with wildly inflated reported durations (400s+) when run alongside ~8 other files, but passed in under 200ms when run alone or with vitest run <file> --pool=forks --poolOptions.forks.singleFork=true. Before concluding a timeout is a real regression, re-run just the failing file in isolation. However, --pool=forks --singleFork=true must never be used for a full-suite run — forcing every spec file into one process removes the per-file isolation Vitest normally provides (each file/worker gets a fresh module registry), and unrelated files' mocks/global state (e.g. Select.spec.tsx, Filter.spec.tsx, ActionHeader.spec.tsx — nothing to do with WorkOrder) started failing when the entire ~140-file suite was forced through one fork. The plain default npx vitest run (no pool override — same as CI) is the only trustworthy way to validate the full suite; reach for the single-fork flag only to de-flake one already-isolated file or small directory.
  • mockReset: true is set globally (vitest.config.ts), meaning Vitest calls the equivalent of vi.resetAllMocks() before every test — this clears not just call history but also any mockImplementation/mockReturnValue set on a vi.fn(). A vi.mock('some/module', () => ({ foo: vi.fn().mockReturnValue(defaultValue) })) pattern therefore does not give foo a stable default across tests — the default gets wiped before the first test even runs, and every test must re-establish behavior itself (in its own beforeEach/test body) via vi.mocked(foo).mockReturnValue(...). The established workaround for a shared, stable default (see shipment.controller.spec.ts's parseSkuInfos mock) is to make the mock export a plain function, not a vi.fn() — a manually-provided vi.mock() factory's plain function exports are not tracked by Vitest's mock registry and are therefore immune to mockReset/clearAllMocks.
  • vi.mock('some/module') with no factory (auto-mock) still evaluates the real module once, to introspect its real exports before replacing them with mocks. If the real module has import-time side effects that throw under a test's mocked environment (e.g. clients/shopifyClient/shopifyClient.ts calls shopifyApi({...}) at module scope, which throws if the test's vi.mock('utils/envConfig', ...) replacement lacks real SHOPIFY_* values), auto-mocking does not protect against that — the real top-level code still runs during the introspection pass. Any spec whose import chain transitively reaches shopifyClient.ts under a stripped-down fake envConfig mock (as of INFRA-590: purchaseorder.router.spec.ts, shipment.router.spec.ts, shippinglabel.router.spec.ts, workorder.router.spec.ts — all reach it via services/workorder or services/shipment importing services/product) must provide an explicit factory — vi.mock('clients/shopifyClient/shopifyClient', () => ({ shopifyGetProductImageBySku: vi.fn(), shopifyGetProductMediaByTitle: vi.fn() })) — to skip real-module evaluation entirely.
  • A .spec.ts (not .spec.tsx) file needs a // @vitest-environment jsdom docblock as its first line to use renderHook/React Testing Library. vitest.config.ts's environmentMatchGlobs only gives jsdom to *.spec.tsx; a .ts file (no JSX, but still testing a React hook — e.g. WorkOrderTemplate/utils.spec.ts#useWorkOrder) defaults to the node environment and renderHook fails with ReferenceError: document is not defined. Timeline/hooks/useTimelineParams.spec.ts already established this pattern; reuse it rather than renaming the file to .tsx.
  • The global models/mock/index.ts SizeRange.findMany mock only filtered by where.sizeRangeId.in, silently ignoring any other where shape (returning all fixture rows unfiltered). INFRA-590's _resolveJewelrySizeRangeIds() queries by where: { name: { in: [...] } } instead — fixed the shared mock to also filter on where.name.in so specs get a correct (usually empty) result instead of a false-positive "everything matches." Worth checking this mock's filter branches stay in sync if a new query shape against SizeRange/Style/etc. gets added elsewhere — the shared mocks in models/mock/*.ts only support the where shapes their existing callers have needed so far.

Classification Data Model & Repair Surface (INFRA-585 / INFRA-648 grooming)​

The write path (position assignment) and the read path (display by position) are joined only by the position integer — classification is never consulted at read time. ProductService.getImagesBySku is a pure position query (where: { sku, position: { lte: first-1 } }, orderBy: position asc), and WorkOrderMaterial.tsx renders skuImages[0] / skuImages[1] by array index. So whatever classifyProductImagePosBySku writes into position is displayed as Front/Back unconditionally, with no confidence or classification check.

  • _getIdForClassification has three fallback tiers (src/utils/classifyProductImagePos.ts:186-213), and only the first is evidence-based: (1) highest-confidence image actually classified as that label; (2) the first image not classified as the opposite label — an other / null / 'failed' image can win the Back slot here; (3) the lowest-confidence image overall, regardless of classification. Tiers 2 and 3 are why an unrelated lifestyle/group shot can land at position: 1. Note also _classifyImageWithGPT4o returns confidence: 1 on failure (failedResult), so a hard AI failure looks maximally confident to tier 3's lowest-confidence comparison.
  • confidence is not persisted. There is no confidence column on ProductImage (verified against prisma/schema.prisma and every migration). It exists only in-memory in ImageForClassify during one classify run and is shipped to Sentry as extra by captureProductImagesClassifyDone. ProductImageModel.updateManyPosition writes only id / position / classification (+ classifiedBy). Any safeguard keyed on "was this a low-confidence pick?" therefore needs a new nullable column and a re-classify pass — historical rows cannot be audited at all.
  • A re-sync leaves classification metadata stale, not cleared. ProductImageModel.upsertMany's ON CONFLICT ("sku","position") DO UPDATE SET ... updates imageUrl / altText / dimensions / Shopify ids / syncedAt / updatedAt / updatedBy — it does not touch classification / classifiedAt / classifiedBy. Since a sync writes images back in raw Shopify order while the previous classify pass had reordered them, the row at a given position after a re-sync carries a new imageUrl alongside the previous image's classification. Between syncImages and classifyProductImagePosBySku the two columns are mutually inconsistent, and if classify fails (it's a separate try/catch in syncStyleImagesBestEffort and only Sentry-reported) the mismatch persists indefinitely. Anything diagnosing "what was this row classified as?" must cross-check classifiedAt against syncedAt before trusting classification.
  • updateManyPosition writes in two phases inside one transaction (classifyProductImagePos.ts:94-120): first every row's position is bumped by a random randomInt(100000, 999999) offset (classifiedBy: 'mes-temporary-update-pos'), then the real positions are written (classifiedBy: 'ai'). This exists to dodge the @@unique([sku, position]) constraint during a reorder — a direct swap would collide. Any new writer that reorders positions must use the same two-phase trick.
  • The AdminJS ProductImage resource does not expose the classification columns. src/routers/admin/resources/productImage/productImage.ts sets listProperties / filterProperties to sku, position, productTitle, variantTitle, imageUrl, syncedAt only — no classification / classifiedAt / classifiedBy (they appear on Show only, via AdminJS's show-all default). There is also no re-sync or re-classify action on either the ProductImage or Style resource (style.ts actions: show, edit, new, list, viewConstructionNotes, editConstructionNotes, adminEdit, delete, bulkDelete, addStylePrepareDatas, findUniqueByStyleNumber). Consequences: an operator cannot see or filter classification from the UI, cannot spot a style with zero ProductImage rows at all (the list only shows rows that exist), and every repair today needs either an API key + curl POST /api/product/v1/syncImages/:sku or shell access for npm run classify:product:images:by:sku -- <SKU> / classify:all:product:images (scripts/classify-product-images-by-sku.ts, scripts/classify-all-product-images.ts — neither follows the scripts/ conventions: no parseArgs, no --help, no --dry-run).

Canonical-Color Fallback (Sage → Black), and the real-variant-sku storage key​

INFRA-648 adds a second canonical color: when the Sage (GR0018) SKU yields nothing usable, retry with Black (BK0001). Verified against prisma/seed/data/colors.json (268 rows): GR0018 = Sage, BK0001 = Black — BK0001 is the first entry in the file and the only plain "Black" (BK0002 = Black Bows, BK0003 = Black Dot, BK0004 = Black/Dark Brown), so it is the correct literal for a generic black fallback.

The fallback trigger is "no usable image", not "no variant" (INFRA-674). See the dedicated subsection below — gating on variant existence alone was a live bug.

  • There are two hardcoded Sage literals, on opposite sides of the server/client boundary:

    1. src/constants/productImage.ts:9 — PRODUCT_IMAGE_DEFAULT_COLOR_CODE, the write-path source of truth used by buildStyleImageSku and by prisma/seed/seed-styles.ts:242 (which imports it rather than re-declaring — INFRA-648 consolidated a formerly-inline const colorCode = 'GR0018' there).
    2. src/components/config.ts:11 — colorCodeForImages, the frontend read-path literal used by makeSkuForGetImages. Duplicated across the boundary on purpose (same pattern as jewelrySizeCodeForImages); the frontend cannot import from src/constants/.

    Two further mentions are not call sites and won't break behavior, but go stale silently: the doc comment at src/services/product/product.service.ts:232, and scripts/spike-render-wo-pdf.mts:763 (a spike script with its own inline ${styleNumber}GR0018004).

  • Storage key = the resolved variant's real Shopify sku (revised from INFRA-648's original "always store under Sage" decision). A Black-fallback sync writes under the BK0001 key, and any pre-existing GR0018 rows are left in place — dual-key storage is explicitly accepted:

    • Nothing reads the color segment of a stored ProductImage.sku. The four sku.substring(5, 11) call sites all parse something else: skuUtils.ts:42/:116 parse work-order/Fulfil SKUs, product.service.ts:308 parses the request sku in the Jewelry-only syncImagesForJewelrySku, and seed-productimage.ts:143 reads Shopify's variant.sku. Provenance is preserved regardless via shopifyProductId / shopifyVariantId / variantTitle, which persistProductImages already stores.
    • Both keys' image sets are same-source. A style's Sage and Black rows both come from the same Shopify product's media (a Dress product's media does not vary by colorway), so whichever key a reader lands on, the images are equivalent for Work Order display.
    • The batch read path may pick either sku for a dual-keyed style. _findManyForSkus({ useStyleNumToFind: true }) selects representative SKUs with findMany({ where: { OR: [...startsWith styleNum], position: 0 }, select: { sku: true } }) — no orderBy — then builds styleNum2SkuMap = new Map(records.map(r => [styleNum, r.sku])), where Map.set lets whichever row the query happens to return last win. With rows under both GR0018 and BK0001 for one style, WorkOrderList / BatchDownloadWorkorder can silently prefer either — accepted, per the same-source argument above.
    • Classification must target the persisted key. classifyProductImagePosBySku queries by exact sku, so all three callers (controller setImmediate, syncStyleImagesBestEffort, seed-productimage.ts) use the persistedSku returned by syncImages — classifying under the requested Sage sku after a Black-fallback sync would find zero rows and silently no-op.
  • Consequence: no frontend change is needed. makeSkuForGetImages keeps emitting the Sage sku. Styles stored under the Sage key (the common case — everything synced before this change, and every style whose Sage variant exists) hit the exact-match lookup as before. Styles stored only under the Black key miss the exact match and resolve via the non-Jewelry style-number-prefix fallback in getImagesBySku / getSkusImages — one extra DB query per miss, an acceptable cost. The fallback covers both the single (startsWith(styleNum)) and batch (styleNums prefix → representative sku) read paths.

  • Implement the fallback at Shopify-resolution time, not by catching an error. syncImages throws makeBadRequestError('...No product found for sku: ...') for a no-variant result, but propagates transient Shopify failures (5xx/timeout, via makeRetryClient) as their own errors. Sniffing the message string would conflate "Sage genuinely doesn't exist" with "Shopify was briefly down" — and a Shopify outage must not be recorded as a color fallback. Prefer resolving the variant first (try Sage, then Black) and calling persistProductImages once with whichever resolved.

  • Size is a separate axis and is not covered by a color fallback. buildStyleImageSku also hardcodes size L → sizeCode '004', looked up from prisma/seed/data/sizes.json (not the DB). A style with no L variant at all in Shopify fails for Sage and Black identically. Adjacent gap, not fixed by this change.

The fallback trigger is "no usable image", not "no variant" (INFRA-674)​

_resolveStyleVariant originally returned the first resolved variant unconditionally (if (variant) return {...}). A Shopify product can be ACTIVE with a real variant and zero media, which satisfied that guard — so the BK0001 query was never issued and the style got no images at all. Observed on AA581 "Davina Tulle Dress": AA581GR0018004 resolves "Davina Tulle Dress - Sage" (ACTIVE) with media: { nodes: [] }, while AA581BK0001004 carries all four images.

  • _hasUsableImage deliberately mirrors persistProductImages' own filter — an IMAGE-type node that also has a preview.image.url — rather than just counting media.nodes. Anything that filter would drop yields zero ProductImage rows and is therefore no better than no variant: a video-only product and a product whose image nodes carry no preview url both fall through to Black. It is a type predicate so the narrowed variant can be used directly in the fallback branch. Note media.nodes can be null, not just empty, in real responses (the pre-existing should handle missing media nodes gracefully spec fixture relies on it) — hence full optional chaining.
  • Null is reserved for "matches nothing on Shopify at all". When neither colorway has a usable image, whichever variant did resolve is still returned — requested colorway first, else the fallback. Otherwise the same real-world condition ("no images anywhere") would sometimes throw No product found for sku and sometimes return 0 images, depending only on whether the Sage variant happened to exist. Callers treat an empty image set as "nothing to store", and the zero-count signal (below) is what surfaces it.
  • An empty media set must not reach the transaction. persistProductImages returns [] before $transaction when the deduped image list is empty. Its stale-row cleanup is deleteMany({ where: { sku, position: { gte: imageMediasRemoveDuplicate.length } } }) — at a length of 0 that matches every row for the sku, so an imageless or video-only Shopify response silently wiped previously-good images. Nothing to write means nothing to delete. Any future writer that mirrors this upsert/cleanup pair needs the same guard.
  • A zero-image sync is not a success. syncImages doesn't throw for a resolved-but-imageless variant, so syncStyleImagesBestEffort returns error: undefined, count: 0. resyncProductImagesActionHandler therefore branches three ways — threw → error + Sentry pointer; count === 0 → error naming the real cause; otherwise → success including the count. Reporting the middle case green is what let AA581 look re-synced while staying imageless.
  • Gap: the zero-image outcome emits no Sentry event. captureSyncProductImageFailed only fires when something throws. The admin action surfaces it via the red notice, but the Airtable-triggered automatic sync (syncStyleImagesBestEffort from the webhooks path) has no UI, so a style silently going imageless there is invisible. Not addressed by INFRA-674.

Fallback-tier forensics and the position: 0 collision (INFRA-585)​

  • The fallback tier that produced a row is recoverable from classification alone — no confidence column needed for the diagnosis. Because tier 2 only runs when no image was classified as the target label, and tier 3 only runs when every image was classified as the opposite label, the surviving classification on the position: 1 row identifies the tier unambiguously: back → tier 1 (evidence-based); other or NULL → tier 2 (an other-classified or AI-failed image won the Back slot; 'failed' normalizes to NULL at classifyProductImagePos.ts:86-90). Note a never-examined image ('init', also normalized to NULL) can never reach tier 2: the early-break in _classifyEnoughFrontBackImages requires backEnough, which requires at least one back classification, which would have satisfied tier 1. So when tier 2 fires, every image was examined — a NULL at position: 1 means the AI errored on that image, not that it was skipped; front → tier 3. What isn't recoverable is the confidence of a tier-1 pick — a genuine back classified at 0.30 is indistinguishable in the DB from one at 0.99. So "was this a low-confidence pick?" needs a new column, but "was this a fallback pick?" does not.
  • confirmedFrontId and confirmedBackId can resolve to the same image, which loses position: 0 entirely. When every image is classified front (including the single-image case), the Back lookup falls through tier 1 (no back) and tier 2 (nothing that isn't front) into tier 3, which returns the same id the Front lookup already won. The caller (classifyProductImagePos.ts:71-83) then sets that image's newPosition = 0 and immediately overwrites it with 1, while beginIdxForNotFrontBackRecord becomes 2 — so no row is left at position: 0. Since getImagesBySku selects position <= first-1 ordered ascending and the frontend renders by array index, the resulting [row@pos1] array puts that image in the Front slot and leaves the Back slot as "No Image". There is no guard against confirmedFrontId === confirmedBackId.
  • Rendering is by array index, not by position value (WorkOrderMaterial.tsx:58,64 — skuImages?.[0] / skuImages?.[1]). Any server-side safeguard that omits a row must only ever drop the tail: dropping position: 0 while keeping position: 1 shifts the back image into the front slot. SkuImage renders <span>No Image</span> for an undefined item (WorkOrderMaterial.tsx:83), so simply returning a shorter array is the correct way to blank the Back slot.
  • ProductSyncImagesItem does not carry classification (src/schemas/product/products.types.d.ts:19-25 — only imageUrl, altText, position, width, height). A display-time safeguard therefore needs either a new field on this type (plus the two response schemas that embed it) or must be enforced entirely backend-side in getImagesBySku / getSkusImages.
  • Migrations in this repo are hand-authored, named <YYYYMMDDHHMMSS>_<snake_case_description>/migration.sql (e.g. 20260811000000_activate_aolong_scarf_lead_time). Local DB drift means prisma migrate dev prompts a reset — author the directory and migration.sql by hand instead.

Measured behaviour of the front/back classifier (INFRA-585, BG183)​

Measured with scripts/diagnose-product-image-classification.ts (read-only; runs the real prompt + selection via the exported analyzeClassification, writes nothing) over BG183's 11 real Shopify images.

  • Confidence scores are coarsely quantised — every observed value was a multiple of 0.05, and almost everything lands on 0.85. Across two identical runs on the default rubric, BG183's five front images all scored 0.85 and its three back images scored 0.85 / 0.75 / 0.75. Treat confidence as a ~4-level ordinal, not a continuous score.
  • Ties are broken by array order, i.e. by Shopify's arbitrary image order. Tier 1 reduces with a strict item.confidence > best.confidence, so the earliest maximum wins. BG183's front slot is a five-way tie at 0.85 (positions 0, 3, 4, 5, 7) decided purely by input order — and positions 3 and 7 are plus-size model shots, so a Shopify reorder can silently swap the front reference between the regular and plus-size model. The Back slot had the same exposure with a one-step (0.10) margin.
  • Each image is classified in its own API call with no shared context, so input order cannot change an individual image's score — it only determines which images are reached before the early break, and who wins a tie.
  • _classifyImageWithGPT4o returns confidence: 1 on failure (failedResult), which is live-observable: a Shopify CDN fetch timing out inside OpenAI (400 Unable to download content from the provided URL before the timeout) yields classification: 'failed' → normalised to NULL → and a confidence of 1.00. Such an image is still a valid tier-2 candidate (NULL !== 'front'), so an image the model never saw can win a slot.
  • The early break couples accuracy to cost, in both directions. backEnough requires a ≥0.9 back or three backs. On the default rubric BG183 got three backs and stopped after 9 of 11 images (positions 9, 10 left 'init' → NULL). A rubric that correctly rejects obliques leaves only one back candidate scoring 0.85 (< the 0.9 enoughConfidence), so backEnough never trips and all 11 images get classified — more accurate but ~22% more calls, plus a larger prompt (3676 → 3978 tokens/call). Getting a canonical back to ≥0.9 would short-circuit at that image and be cheaper than today; scoring it at 0.85 is what makes the stricter rubric cost more.
  • Where a hard rule lives determines whether a caller can delete it. classificationRules (POST /api/product/v1/syncImages/:sku) overrides only the confidence bands — front/back arrays of {range,label,criteria}, all free-form strings with no length cap. The "front"/"back"/"other" classification rules in the system prompt are hardcoded and not overridable. So a safeguard placed in backConfidenceInfos is silently removed by any caller passing its own classificationRules.back, whereas one placed in the classification-rules section survives every override. Put must-hold invariants (e.g. "a visible face is not a back view") in the classification section; keep only scoring guidance in the bands.
  • Empirically effective discriminator for this catalog: face visibility. In BG183 the correct square-on back (position 6) shows only the back of the head, while both mis-selected candidates (positions 1 and 8) show the face turned back over the shoulder. Adding "ANY part of the face visible ⇒ return other, never a low-confidence back" moved positions 1 and 8 from back to other in both runs, reducing the back candidate pool from 3 to 1 — which removes the tie-break/variance exposure entirely rather than trying to win the comparison. Conversely the default rubric's "No hair obscuring the upper back or neckline" criterion penalises correct images: shoulder-length hair on the upper back is a constant across this catalog, so it demotes canonical backs out of the 0.9+ band instead of discriminating between candidates.

Standard-size preference in slot selection (INFRA-585)​

Once obliques stop being classified back, BG183 still has two legitimate square-on back views — one on the standard-size model, one on the plus-size model — and both score 0.85. With quantised scores and a tier-1 reduce that keeps the first maximum, the winner was decided by nothing but Shopify's image order, so an upstream reorder could silently swap which model the factory floor saw. The same applied to the front slot (BG183 has three standard-size and two plus-size front photos, all at 0.85).

  • Rule: prefer standard-size; use a plus-size image only when no qualified standard-size candidate exists for that slot. Implemented as _preferStandardSize(candidates) in classifyProductImagePos.ts, applied per tier inside _getIdForClassification.
  • Per tier, not to the pool up front. A style can have several standard-size images that are all front plus a single plus-size back; filtering the whole pool to standard-size first would discard that back and let tier 3 promote a second front photo into the Back slot. Filtering within each tier keeps the plus-size back winning tier 1, which is the desired outcome.
  • Detection is a filename heuristic — /plus(?:[-_\s]|%20)?size/i against ProductImage.imageUrl (e.g. sage_veronica_plus_size_matte_satin_bridesmaid_dress_04.jpg). Shopify exposes no structured "plus-size model" flag on product media, and variantTitle is identical across a style's images (all L / SAGE / Standard Production), so the asset filename is the only available signal. The separator is optional and allows a URL-encoded space, since the name is human-entered. If a style's naming convention ever omits the marker, every candidate reads as standard-size and selection degrades to the previous highest-confidence behaviour rather than breaking.
  • Verified order-independent by running the harness over BG183's images in reverse: with the plus-size back placed first, the standard-size back still won the slot.
  • Residual, cosmetic: which of several equally-scored standard-size front photos wins still depends on Shopify order (BG183 picked dress_01 in natural order, dress_03 reversed). All are correct front views of the same garment on the same model, so this is a stability nit rather than a wrong image — but it means a Work Order's front photo can change across an unrelated re-sync.

Slot selection: side as a distinct label, and why Front/Back can no longer collide (INFRA-585)​

Selection is no longer the original three-tier "highest-confidence, else anything not the opposite label, else lowest-confidence overall" chain. _getIdForClassification is replaced by _selectSlotImages(imagesForClassify), which resolves both slots together:

  • Front: an image classified front → else a side view → else Shopify's first image. The Front slot is therefore always filled, which is safe because Shopify's first image is conventionally the hero shot.
  • Back: an image classified back → else a side view → else left empty.
  • The Back candidate pool always excludes whatever Front took, so the two slots can never resolve to the same image.

ClassificationSelection now carries a named reason ('classified' | 'side-fallback' | 'first-image' | null) rather than a tier number, because the chain is no longer a numbered ladder.

Why side is a separate label. An oblique shot is a poor competitor to a true square-on back but a perfectly reasonable substitute when a style has none — whereas a lifestyle/group/detail/flat-lay shot must never reach a slot at all. Folding both into other made those cases indistinguishable. The prompt's Classification Rules now define four categories: front, back, side (garment is the clear single subject but viewed obliquely, or the model is turned away with part of the face visible), and other (garment is not the clear single subject). Measured on BG183, the model separates them cleanly and unprompted by score: sides land at 0.70–0.75, the lifestyle shot at 0.00.

side is in-memory only. The ProductImageClassification enum is front | back | other and this repo has local DB drift that makes migrations awkward, so _assignNewPositions folds side into other before persisting — no schema change. Consequence: the stored classification cannot distinguish a side view from a lifestyle shot, so any diagnosis must read the AI label, not the stored one. analyzeClassification therefore snapshots the raw labels before normalisation and returns them as aiClassification alongside the persisted classification; the diagnostic script prints both columns. Promoting side to a real enum member is the follow-up if display-time logic ever needs the distinction.

Position 1 is reserved even when the Back slot is empty. beginIdxForNotFrontBackRecord is 2 whenever a Front image exists, so leftover images start at position 2 and a gap is deliberately left at 1. Letting them start at 1 would drop an arbitrary leftover into the Back slot — the exact failure the selection chain exists to prevent. The gap makes getImagesBySku return a shorter array (it selects position <= first - 1), and the template renders No Image for the missing Back cell.

The collision this replaces was not an edge case. confirmedFrontId === confirmedBackId was guaranteed for (a) any single-image non-Jewelry style, (b) any style whose images all shared one label at a tied confidence — common, given scores are quantised and both reduces use strict comparisons that keep the first element — and (c) every style reclassified during an OpenAI outage: all images come back 'failed', so neither lookup matched a label and both fell through to the same fallback candidate. The back assignment then overwrote the front's newPosition = 0 with 1, leaving nothing at position 0; since the read path renders by array index, the front photo appeared in the Front cell and the Back cell showed No Image, with any real back photo stranded at position 2+ where the read path never looks. Case (c) was silent — _classifyImageWithGPT4o swallows the error and returns failedResult, so the pass committed normally and only captureProductImagesClassifyDone recorded it, as a success.