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>+ colorGR0018(Sage) + L-size code004. 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 fromprisma/seed/data/sizes.json(size === 'L'→sizeCode '004') — not from the DB. The color/size literals live insrc/constants/productImage.tsas 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.tsandproduct.service.tsboth import from here). - Sage → Black fallback (INFRA-648):
syncImagesresolves 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 (_resolveStyleVariantreturns{ variant, resolvedSku }) — for a Black-fallback sync that is theBK0001key, 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 ingetImagesBySku/getSkusImagescovers the rest — see "Canonical-Color Fallback" below for the full analysis). Jewelry paths (syncJewelryStyleColorImages/syncImagesForJewelrySku) do not usesyncImagesand have no fallback. Since all four trigger points funnel throughsyncImages, the fallback applies to all of them automatically. - Classification reset on re-sync (INFRA-648):
ProductImageModel.upsertMany'sON CONFLICT ("sku","position") DO UPDATEnow also resetsclassification/classifiedAt/classifiedByto 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 newimageUrlnext to the previous image'sclassificationindefinitely when the classify pass fails. After this fix, INFRA-585-style diagnoses can trustclassificationwithout cross-checkingclassifiedAtvssyncedAt. - 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), filtersmedia.nodestoMediaContentType.Image, dedupes by URL, then in a$transaction(20s timeout)upsertManys the images (position-ordered) anddeleteManys any rows atposition >= newCount— all keyed byresolvedSku, the variant's real Shopify sku. Returns{ images, persistedSku };persistedSkudiffers from the requested sku exactly when the Black fallback resolved. ThrowsmakeBadRequestErrorif no variant matches the SKU. The optionalprismaInstanceparam exists so the standalone seed script'snew PrismaClient()can be threaded through. - AI ordering:
classifyProductImagePosBySku(sku, rules?)(src/utils/classifyProductImagePos.ts) uses OpenAI to classify each image front/back/other and rewritesposition(front→0, back→1, rest after). Called as a separate step aftersyncImages. syncStyleImagesBestEffortreturn contract (INFRA-648): returns{ sku: string | null; error?: any; count: number }, not the oldstring | null.skuis the persisted sku — the canonical Sage sku normally, the Black (BK0001) sku when the fallback resolved, ornullifbuildStyleImageSkufails.erroris set on sync or classify failure (classification failure does not fail the sync —skuis still returned andcaptureSyncProductImageFailedis not called), andcountis 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.resyncProductImagesActionHandlerreadserrorto pick the notice type andcountto stamprecord.params.imagesCount. Callers that onlyvoidthe result (newActionHandler,webhooks.service.ts) are unaffected by the richer return.- Trigger points (4 call sites for
syncImages):- REST API —
POST /api/product/v1/syncImages/:sku(apiKeyAuth, controllersrc/controllers/product/product.controller.ts:23). Validates, callssyncImages, thensetImmediate(classifyProductImagePosBySku). On error callscaptureSyncProductImageFailed(req, err)and rethrows. This is the N8N-driven path. - AdminJS New-Style form —
newActionHandler(src/routers/admin/resources/style/handlers.ts:75-77) runssetImmediate(() => void ProductService.syncStyleImagesBestEffort(styleUpdateParams.styleNumber))after creating the style. This is fire-and-forget with all failures swallowed —syncStyleImagesBestEffortis 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 theresyncProductImagesrecord action or the--missing-onlyseed. - 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 buildssuccessSkus/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 zeroProductImagerows 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.filterStylesWithMissingImagesusesfindMany({ select: { sku: true }, distinct: ['sku'] }), which required enabling thenativeDistinctPrisma preview feature inprisma/schema.prisma— keep that flag if the distinct query shape is preserved. - AdminJS Edit-Style — none.
editActionHandlernever re-syncs images. - AdminJS Style
resyncProductImagesrecord action (INFRA-648) —src/routers/admin/resources/style/style.ts+handlers.ts.actionType: 'record',adminRoleAuth,component: false; callsProductService.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.
- REST API —
- Style list
imagesCountvirtual column (INFRA-648):listActionHandler(src/routers/admin/resources/style/listActionHandler.ts:57-74) enriches each listed style with animagesCountvirtual column — a per-style ProductImage row count. BecauseProductImagehas no FK toStyle, the link is theskuprefix (sku.substring(0, styleNumber.length) === styleNumber); the count is fetched with a singleProductImageModel.groupByStyleNumbers(styleNumbers)call (src/models/productImage/productImage.model.ts:143-157,groupByonskuwith_count: { _all: true }and anORofsku.startsWithper styleNumber), then aggregated back in JS by matching each group'sskuprefix to its styleNumber. Declared as atype: 'number'virtual property instyle.ts:155and appended tolistProperties. This gives operators an at-a-glance "has images?" signal on the Style list, complementing theresyncProductImagesrepair action. - Operator runbook (zero-image styles): a style with zero
ProductImagerows is structurally invisible in the ProductImage list (it can only show rows that exist), but is now visible via the Style list'simagesCountcolumn. Repair path: open the Style in AdminJS → runresyncProductImages→ the notice shows success/failure (failures point to Sentrymodule=productImage, processName=syncProductImage). The ProductImage list/filter now exposesclassification/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 runssetImmediate(() => 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 onlyreq.params?.sku— so it was usable only from the controller, not background/webhook callers (tags:module=productImage,processName=syncProductImage,eventType=failed). Constants live insrc/constants/sentryTags.ts(SENTRY_SYNC_PRODUCT_IMAGE_EVENT_TYPE.failed).captureProductImagesClassifyDonelogs classify outcomes. - Specs:
product.service.spec.tsexists (mocksshopifyClient,prismaClient.$transaction,productImagemodel; usestest());webhooks.service.spec.tsexists;listActionHandler.spec.tscovers theimagesCountenrichment. The StyleresyncProductImagesActionHandlerandgroupByStyleNumbershave no direct spec (the latter is a thinhandleDbErrwrapper over PrismagroupBy).
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: nullon every variant. Size+Carat families (Ring+Carat, Necklace+Carat, Ring+Carat Two Stone, Earring Carat) do have SKUs, but they're sequential placeholders likeTEST-FJ-0184— unrelated to MES'sstyleNumber+colorCode+sizeCodescheme. Neither family is reachable bysku: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 genericcolor = "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 exactStyle.styleNameEnmatch). For the colorway-split families, the Shopify product title further appends" - " + <attr.color>, and MES'sColor.colorNameEnalready has matching rows (Green Amethyst,White Topaz,Blue Topaz,Diamond, etc.) — so the full lookup is: trytitle:"<styleNameEn>"first (works for Diamond/Carat family), fall back totitle:"<styleNameEn> - <colorNameEn>"using the Work Order's actualcolorId(works for colorway-split families). No Shopify product ID/handle is captured anywhere in MES's Airtable→Style pipeline (AirtableStyleWebhookBodySchemaonly carriesstyleNumber/styleName/sizeRangeRecordId), so title reconstruction is the only available link. - Detecting "is this Style Jewelry" must be done by SizeRange name, not
sizeRangeIdor 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 differentsizeRangeIdandairtableRecordIdvalues 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.nameis the only field confirmed stable across environments — mirrors the existing codebase precedent of matching Milly/Jewelry factories byfactoryNameEnrather thanfactoryId/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 bulkPOST /shipment/.../create→shipment.controller.ts'screate()→workorderService.batchCreateByTransaction(inside aprisma.$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 onwhere, not oninclude/select. Multiple call sites (skuUtils.ts#parseSkuInfo,style.service.ts,webhooks.service.ts) callfindStyleUnique({ where: { styleNumber } })with noinclude. Addinginclude: { sizeRange: true }to one caller risks a stale/narrower cached object (populated by a different caller with noinclude) silently missing the relation, or vice versa. Do not add a newincludeshape to an existingfindStyleUniquecall site that shares awhereshape with other callers — do a separate, differently-keyed lookup instead (e.g. viaSizeRangeModel, a different cache namespace) when a caller needs an additional relation the existing callers don't. This is whyparseSkuInfo(singular) resolves the SizeRange name via its ownSizeRangeModel.findSizeRangeUnique({ sizeRangeId })call rather than addinginclude: { sizeRange: true }to its existingfindStyleUniquecall. - MES already carries everything needed to resolve Jewelry colorways —
Colortable has real rows for gemstone names (Green AmethystGR0040,White TopazWT0016,Blue TopazBL0048,DiamondWT0015) alongside metal-tone colors (GoldNT0009,White GoldNT0024,Yellow GoldNT0025,Rose GoldPK0026) — 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 bothparseSkuInfo(singular; resolved via a dedicatedSizeRangeModel.findSizeRangeUniquecall, run in parallel with the existing size lookup) andparseSkuInfos(batch; populated from thesizeRangeId2NameMapit already built internally for error messages — no new query needed there).src/clients/shopifyClient/shopifyClient.ts—shopifyGetProductMediaByTitle(title): GraphQLproducts(first:1, query: 'title:"<title>"'), returns the product'smediadirectly (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 sharedpersistProductImages(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`(syntheticProductImage.skucache 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 unlessisJewelrySizeRangeName(sizeRangeName)— the single entry point both creation paths call). UnlikesyncStyleImagesBestEffort, this does not runclassifyProductImagePosBySku— Jewelry has exactly one image, so there's no front/back to classify.- Hooked into
workorder.service.ts#create(singular path, fires afterworkOrderModel.createsucceeds) andshipment.controller.ts#create(batch path, fires in the existing post-transaction "don't block the response" section, deduped by`${styleId}-${colorId}`across the wholeskuNumber2SkuInfoMapso 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 batchWorkOrderBatchInfosByPoIdResponseSchemainschemas/workorder/index.ts; the singularWorkOrderResponseSchemauses.passthrough()so it didn't need a change). Computed by a shared_resolveJewelrySizeRangeIds()helper inworkorder.service.ts— oneSizeRangeService.findMany({ where: { name: { in: [...JEWELRY_SIZE_RANGE_NAMES] } } })call per request, returning aSet<sizeRangeId>— called once inmakeWorkOrderData(single WO) and once in_makeWorkOrderDatas(batch, computed outside the per-WO.map()so it's not re-queried per row). Threaded through_createWorkOrderDataByRecords'srecordsparam alongsideenableMeasurements/enableConstructionNotes.src/components/config.ts#makeSkuForGetImages(style, colorCode?, isJewelry?)— gained two optional params. WhenisJewelry && colorCode, builds`${style}${colorCode}000`(must exactly match the backend'sProductService.buildJewelrySkusynthetic key, including the'000'size placeholder — duplicated asjewelrySizeCodeForImagesin this file, mirroring the existingcolorCodeForImages/sizeCodeForImagesDress-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 juststyle.WorkOrderTemplate/utils.tsx#useWorkOrder—getSkuImagesnow takes(sku, colorCode, isJewelry), reading the realcolor.colorCode/isJewelryoff the fetchedWorkOrderData, and requestsfetchSkuImages(sku, isJewelry ? 1 : 2).WorkOrderMaterial.tsx— the materials table's image column was already built on arowSpantrick (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 differentrowSpanmath.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 becausemakeSkuForGetImagesused to ignore color entirely. Now keys by a newmakeSkuForGetImagesForWorkOrder(workOrderData)helper (style + the WO's owncolor.colorCode+isJewelry) — for Dress this still collapses to one shared key per style (sincemakeSkuForGetImagesignorescolorCodeunlessisJewelryis true, so behavior is unchanged), but for Jewelry it correctly fetches a distinct image per (style, color) pair.firstcount checks every WO in the batch (firstData.isJewelry && workOrderDatas.every((wo) => wo.isJewelry)), not justfirstDataalone — falls back to 2 unless the whole batch agrees it's Jewelry (see the "Mixed batch" defensive guard test inpagesUtils.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/enableConstructionNotesdefault totrue/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 retiredenv.FACTORY_CODE_AOLONG_SCARFonto the Aolong factory code by INFRA-633, which folded Aolong Scarf into the Aolong Factory row). The second branch is deliberately not gated behindmilly_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 thepoCategorycheck 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 newelse 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 viamakeWorkOrderData/makeBatchInfosByPoIdassertions inworkorder.service.spec.ts._resolveJewelrySizeRangeIds()(~line 1361) resolvesisJewelryinputs — 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 aWorkOrderDataobject — bothmakeWorkOrderData(single WO) and_makeWorkOrderDatas(batch, called once per WO inside its.map()) build theirrecordsinput and delegate here. Any new field belongs on this sharedrecordsparam + return object, not duplicated at each call site.- Call-site shape: both
makeWorkOrderDataand_makeWorkOrderDatascomputemillyEnabled/jewelrySizeRangeIdsfirst, call_resolveEnablementFlags(batch: once per request, not per-WO —enableMeasurements/enableConstructionNotesare treated as PO-wide since Milly's gate only depends onfactoryCode+poCategory, both singular per PO), then computeisJewelryper-style (batch: per-WO, inside the.map(), since a style'ssizeRangeIdvaries per WO) before calling_createWorkOrderDataByRecords. Any new resolved field whose truth depends onisJewelry(like a jewelry-specific enablement override) therefore cannot piggyback on the once-per-request_resolveEnablementFlagscall 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
enableConstructionNotesorenableJewelryImages(any future flag that hides Construction Notes for a subset of WOs must update both, or images silently stop loading for that subset):WorkOrderTemplate/utils.tsx#useWorkOrder'sgetSkuImagescall, gated byif (responseWorkOrderData.enableConstructionNotes || responseWorkOrderData.enableJewelryImages). FeedsWorkOrderTemplate.tsxandDashboard/WorkOrderModal.tsx(both call theuseWorkOrderhook directly for a single WO).pagesUtils.ts#workOrderDatasFetchImages'sshouldFetchImages = firstData.enableConstructionNotes || workOrderDatas.some((wo) => wo.enableJewelryImages). TheenableConstructionNoteshalf reads onlyfirstData(safe — it's PO-wide), but theenableJewelryImageshalf uses.some()across the whole batch (NOTfirstDataalone) since it depends on per-WOisJewelryand a mixed batch's Jewelry WOs would otherwise get no images iffirstDataitself isn't Jewelry. FeedsWorkOrderList.tsxandBatchDownloadWorkorder.tsx(both fetch aWorkOrderData[]upfront and callworkOrderDatasFetchImagesonce for the whole batch, not through the hook).
- Work Order Template subcomponent pattern:
WorkOrderConstructionNotes.tsxand single-row sections follow a single-<table>-single-<tr>shape with the section label as the first<td>(bold, translated viauseTranslation().translateComponent('<Component>.<key>')) and content in the following<td>(s) — seeWorkOrderConstructionNotes.tsxfor the minimal example.WorkOrderMeasurement/WorkOrderMaterialare the multi-row variants of the same translation convention. None of these subcomponents are registered incomponentLoader.ts— they're plain React components imported by relative path directly intoWorkOrderMainDisplay.tsx(consistent with the project-wide rule thatsrc/components/**runtime imports must be relative, only AdminJS-mounted components go throughcomponentLoader.ts). - Locale files are flat, not directory-per-locale:
src/locales/en.jsonandsrc/locales/zh_CN.json(notsrc/locales/en/*.jsonas 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/WorkOrderTemplateall 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 viaisFeatureEnabledalongsidemillyEnabledin bothmakeWorkOrderDataand_makeWorkOrderDatas. - New
WorkOrderData.enableJewelryImages: booleanfield — true only whenjewelry_enabledis on andisJewelryis true (never derived fromPurchaseOrder.category, matching the existingisJewelryprecedent, so a Dress-category style is unaffected by the flag regardless of state). Added toworkorder.types.d.tsandWorkOrderBatchInfosByPoIdResponseSchema(the singularWorkOrderResponseSchemaneeds no change —.passthrough()). - New module-private
_applyJewelryImagesOverride(flags, { jewelryEnabled, isJewelry })inworkorder.service.ts, deliberately kept separate from_resolveEnablementFlagsrather than folding jewelry inputs into it:_resolveEnablementFlags(Milly-only) is still called once-per-request in the batch path, while_applyJewelryImagesOverrideis layered on top — once-per-request in the single-WO path (isJewelryknown upfront), but once per WO inside the batch.map()(sinceisJewelryvaries per style within a batch). It returns{ enableMeasurements, enableConstructionNotes, enableJewelryImages }, forcing the first two tofalsewheneverenableJewelryImagesistrue. This two-function split is the reason a mixed Dress+Jewelry batch works correctly: the batch-wide data-loading gate (whether to bother queryingGarmentMeasurement/MaterialConstruction/Measurement/Materialat 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.tsxcomponent (src/components/pages/WorkOrderTemplate/) — single-row-table shape per the subcomponent pattern above: label cell ("Images",WorkOrderImages.imageskey) |${styleName} - ${colorName}text (viagetLocalizedValue, same format INFRA-590 uses for the Shopify title lookup) | the synced photo or aWorkOrderImages.noImagefallback. Wired intoWorkOrderMainDisplay.tsxas a third gated section:{data.enableJewelryImages && <WorkOrderImages .../>}, alongside (not replacing) the existingenableMeasurements/enableConstructionNotesbranches — no extra hiding logic needed there since the backend already zeroes those two out wheneverenableJewelryImagesis 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 theuseWorkOrderone — the batch-printpagesUtils.tsone has the identical structural dependency onenableConstructionNotesand 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 (5stestTimeout) intermittently failed with wildly inflated reported durations (400s+) when run alongside ~8 other files, but passed in under 200ms when run alone or withvitest 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=truemust 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 defaultnpx 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: trueis set globally (vitest.config.ts), meaning Vitest calls the equivalent ofvi.resetAllMocks()before every test — this clears not just call history but also anymockImplementation/mockReturnValueset on avi.fn(). Avi.mock('some/module', () => ({ foo: vi.fn().mockReturnValue(defaultValue) }))pattern therefore does not givefooa stable default across tests — the default gets wiped before the first test even runs, and every test must re-establish behavior itself (in its ownbeforeEach/test body) viavi.mocked(foo).mockReturnValue(...). The established workaround for a shared, stable default (seeshipment.controller.spec.ts'sparseSkuInfosmock) is to make the mock export a plain function, not avi.fn()— a manually-providedvi.mock()factory's plain function exports are not tracked by Vitest's mock registry and are therefore immune tomockReset/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.tscallsshopifyApi({...})at module scope, which throws if the test'svi.mock('utils/envConfig', ...)replacement lacks realSHOPIFY_*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 reachesshopifyClient.tsunder a stripped-down fakeenvConfigmock (as of INFRA-590:purchaseorder.router.spec.ts,shipment.router.spec.ts,shippinglabel.router.spec.ts,workorder.router.spec.ts— all reach it viaservices/workorderorservices/shipmentimportingservices/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 jsdomdocblock as its first line to userenderHook/React Testing Library.vitest.config.ts'senvironmentMatchGlobsonly givesjsdomto*.spec.tsx; a.tsfile (no JSX, but still testing a React hook — e.g.WorkOrderTemplate/utils.spec.ts#useWorkOrder) defaults to thenodeenvironment andrenderHookfails withReferenceError: document is not defined.Timeline/hooks/useTimelineParams.spec.tsalready established this pattern; reuse it rather than renaming the file to.tsx. - The global
models/mock/index.tsSizeRange.findManymock only filtered bywhere.sizeRangeId.in, silently ignoring any otherwhereshape (returning all fixture rows unfiltered). INFRA-590's_resolveJewelrySizeRangeIds()queries bywhere: { name: { in: [...] } }instead — fixed the shared mock to also filter onwhere.name.inso 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 againstSizeRange/Style/etc. gets added elsewhere — the shared mocks inmodels/mock/*.tsonly support thewhereshapes 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.
_getIdForClassificationhas three fallback tiers (src/utils/classifyProductImagePos.ts:186-213), and only the first is evidence-based: (1) highest-confidenceimage actually classified as that label; (2) the first image not classified as the opposite label — another/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 atposition: 1. Note also_classifyImageWithGPT4oreturnsconfidence: 1on failure (failedResult), so a hard AI failure looks maximally confident to tier 3's lowest-confidence comparison.confidenceis not persisted. There is no confidence column onProductImage(verified againstprisma/schema.prismaand every migration). It exists only in-memory inImageForClassifyduring one classify run and is shipped to Sentry asextrabycaptureProductImagesClassifyDone.ProductImageModel.updateManyPositionwrites onlyid/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'sON CONFLICT ("sku","position") DO UPDATE SET ...updatesimageUrl/altText/ dimensions / Shopify ids /syncedAt/updatedAt/updatedBy— it does not touchclassification/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 newimageUrlalongside the previous image'sclassification. BetweensyncImagesandclassifyProductImagePosBySkuthe two columns are mutually inconsistent, and if classify fails (it's a separate try/catch insyncStyleImagesBestEffortand only Sentry-reported) the mismatch persists indefinitely. Anything diagnosing "what was this row classified as?" must cross-checkclassifiedAtagainstsyncedAtbefore trustingclassification. updateManyPositionwrites in two phases inside one transaction (classifyProductImagePos.ts:94-120): first every row's position is bumped by a randomrandomInt(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
ProductImageresource does not expose the classification columns.src/routers/admin/resources/productImage/productImage.tssetslistProperties/filterPropertiestosku,position,productTitle,variantTitle,imageUrl,syncedAtonly — noclassification/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 theProductImageorStyleresource (style.tsactions: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 zeroProductImagerows at all (the list only shows rows that exist), and every repair today needs either an API key +curl POST /api/product/v1/syncImages/:skuor shell access fornpm 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 thescripts/conventions: noparseArgs, 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:
src/constants/productImage.ts:9—PRODUCT_IMAGE_DEFAULT_COLOR_CODE, the write-path source of truth used bybuildStyleImageSkuand byprisma/seed/seed-styles.ts:242(which imports it rather than re-declaring — INFRA-648 consolidated a formerly-inlineconst colorCode = 'GR0018'there).src/components/config.ts:11—colorCodeForImages, the frontend read-path literal used bymakeSkuForGetImages. Duplicated across the boundary on purpose (same pattern asjewelrySizeCodeForImages); the frontend cannot import fromsrc/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, andscripts/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
BK0001key, and any pre-existingGR0018rows are left in place — dual-key storage is explicitly accepted:- Nothing reads the color segment of a stored
ProductImage.sku. The foursku.substring(5, 11)call sites all parse something else:skuUtils.ts:42/:116parse work-order/Fulfil SKUs,product.service.ts:308parses the request sku in the Jewelry-onlysyncImagesForJewelrySku, andseed-productimage.ts:143reads Shopify'svariant.sku. Provenance is preserved regardless viashopifyProductId/shopifyVariantId/variantTitle, whichpersistProductImagesalready 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 withfindMany({ where: { OR: [...startsWith styleNum], position: 0 }, select: { sku: true } })— noorderBy— then buildsstyleNum2SkuMap = new Map(records.map(r => [styleNum, r.sku])), whereMap.setlets whichever row the query happens to return last win. With rows under bothGR0018andBK0001for one style,WorkOrderList/BatchDownloadWorkordercan silently prefer either — accepted, per the same-source argument above. - Classification must target the persisted key.
classifyProductImagePosBySkuqueries by exact sku, so all three callers (controllersetImmediate,syncStyleImagesBestEffort,seed-productimage.ts) use thepersistedSkureturned bysyncImages— classifying under the requested Sage sku after a Black-fallback sync would find zero rows and silently no-op.
- Nothing reads the color segment of a stored
-
Consequence: no frontend change is needed.
makeSkuForGetImageskeeps 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 ingetImagesBySku/getSkusImages— one extra DB query per miss, an acceptable cost. The fallback covers both the single (startsWith(styleNum)) and batch (styleNumsprefix → representative sku) read paths. -
Implement the fallback at Shopify-resolution time, not by catching an error.
syncImagesthrowsmakeBadRequestError('...No product found for sku: ...')for a no-variant result, but propagates transient Shopify failures (5xx/timeout, viamakeRetryClient) 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 callingpersistProductImagesonce with whichever resolved. -
Size is a separate axis and is not covered by a color fallback.
buildStyleImageSkualso hardcodes sizeL→sizeCode '004', looked up fromprisma/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.
_hasUsableImagedeliberately mirrorspersistProductImages' own filter — anIMAGE-type node that also has apreview.image.url— rather than just countingmedia.nodes. Anything that filter would drop yields zeroProductImagerows 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. Notemedia.nodescan be null, not just empty, in real responses (the pre-existingshould handle missing media nodes gracefullyspec 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 skuand 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.
persistProductImagesreturns[]before$transactionwhen the deduped image list is empty. Its stale-row cleanup isdeleteMany({ where: { sku, position: { gte: imageMediasRemoveDuplicate.length } } })— at a length of0that 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.
syncImagesdoesn't throw for a resolved-but-imageless variant, sosyncStyleImagesBestEffortreturnserror: undefined, count: 0.resyncProductImagesActionHandlertherefore 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.
captureSyncProductImageFailedonly fires when something throws. The admin action surfaces it via the red notice, but the Airtable-triggered automatic sync (syncStyleImagesBestEffortfrom 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
classificationalone — 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 survivingclassificationon theposition: 1row identifies the tier unambiguously:back→ tier 1 (evidence-based);otherorNULL→ tier 2 (another-classified or AI-failed image won the Back slot;'failed'normalizes toNULLatclassifyProductImagePos.ts:86-90). Note a never-examined image ('init', also normalized toNULL) can never reach tier 2: the early-break in_classifyEnoughFrontBackImagesrequiresbackEnough, which requires at least onebackclassification, which would have satisfied tier 1. So when tier 2 fires, every image was examined — aNULLatposition: 1means 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 genuinebackclassified 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. confirmedFrontIdandconfirmedBackIdcan resolve to the same image, which losesposition: 0entirely. When every image is classifiedfront(including the single-image case), the Back lookup falls through tier 1 (noback) and tier 2 (nothing that isn'tfront) into tier 3, which returns the same id the Front lookup already won. The caller (classifyProductImagePos.ts:71-83) then sets that image'snewPosition = 0and immediately overwrites it with1, whilebeginIdxForNotFrontBackRecordbecomes2— so no row is left atposition: 0. SincegetImagesBySkuselectsposition <= first-1ordered 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 againstconfirmedFrontId === confirmedBackId.- Rendering is by array index, not by
positionvalue (WorkOrderMaterial.tsx:58,64—skuImages?.[0]/skuImages?.[1]). Any server-side safeguard that omits a row must only ever drop the tail: droppingposition: 0while keepingposition: 1shifts the back image into the front slot.SkuImagerenders<span>No Image</span>for anundefineditem (WorkOrderMaterial.tsx:83), so simply returning a shorter array is the correct way to blank the Back slot. ProductSyncImagesItemdoes not carryclassification(src/schemas/product/products.types.d.ts:19-25— onlyimageUrl,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 ingetImagesBySku/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 meansprisma migrate devprompts a reset — author the directory andmigration.sqlby 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 fivefrontimages all scored0.85and its threebackimages scored0.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.
_classifyImageWithGPT4oreturnsconfidence: 1on 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) yieldsclassification: 'failed'→ normalised toNULL→ 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.
backEnoughrequires a ≥0.9backor threebacks. On the default rubric BG183 got threebacks and stopped after 9 of 11 images (positions 9, 10 left'init'→NULL). A rubric that correctly rejects obliques leaves only onebackcandidate scoring 0.85 (< the 0.9enoughConfidence), sobackEnoughnever 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/backarrays 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 inbackConfidenceInfosis silently removed by any caller passing its ownclassificationRules.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-confidenceback" moved positions 1 and 8 frombacktootherin both runs, reducing thebackcandidate 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)inclassifyProductImagePos.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
frontplus a single plus-sizeback; 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/iagainstProductImage.imageUrl(e.g.sage_veronica_plus_size_matte_satin_bridesmaid_dress_04.jpg). Shopify exposes no structured "plus-size model" flag on product media, andvariantTitleis identical across a style's images (allL / 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_01in natural order,dress_03reversed). 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 asideview → 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 asideview → 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.