[object Object]

← back to Dw Photo Capture

color-index: build from live shopify_products (active + online_store_published) — drop archived/unpublished handles so PDP color grid never links to 404s or dead images

61eee41b8c3285c1c4f59a7da39295dc6fb144db · 2026-07-15 16:30:51 -0700 · Steve Abrams

Files touched

Diff

commit 61eee41b8c3285c1c4f59a7da39295dc6fb144db
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 15 16:30:51 2026 -0700

    color-index: build from live shopify_products (active + online_store_published) — drop archived/unpublished handles so PDP color grid never links to 404s or dead images
---
 scripts/build-color-index.cjs | 50 ++++++++++++++++++++++++++++---------------
 1 file changed, 33 insertions(+), 17 deletions(-)

diff --git a/scripts/build-color-index.cjs b/scripts/build-color-index.cjs
index 1bc9bf5..76b4bcd 100644
--- a/scripts/build-color-index.cjs
+++ b/scripts/build-color-index.cjs
@@ -4,10 +4,15 @@
  * color-dot "index of many products within 10% tolerance" feature.
  *
  * Source of truth: dw_unified.product_colors (one dominant color per product,
- * with a PRECOMPUTED CIELAB lab_l/a/b) joined to the live storefront handle +
- * image. We emit ONE row per ACTIVE product with a real hex + LAB + handle +
- * image, so the /apps/color-index endpoint can do a pure perceptual ΔE filter
- * with no DB dependency at request time.
+ * with a PRECOMPUTED CIELAB lab_l/a/b) for the COLOR, JOINED to dw_unified.
+ * shopify_products for LIVE TRUTH (current status + current featured image).
+ * product_colors.status/image_url are populated by manual per-vendor extracts
+ * and go stale (an archived product can still read status='active' with a dead
+ * CDN image_url), which used to leak broken cards + 404 links into the PDP color
+ * grid. We therefore gate on shopify_products.status='active' (synced from live
+ * Shopify) and prefer its current image_url. We emit ONE row per active product
+ * with a real hex + LAB + handle + image, so the /apps/color-index endpoint can
+ * do a pure perceptual ΔE filter with no DB dependency at request time.
  *
  * Output: data/color-index.json  → { generated_at, count, items:[
  *   { h:"handle", t:"title", v:"vendor", x:"#rrggbb", l:LAB_L, a:LAB_A, b:LAB_B, i:"image_url" } ] }
@@ -32,20 +37,31 @@ const OUT = path.join(__dirname, '..', 'data', 'color-index.json');
 
 (async () => {
   const t0 = Date.now();
-  // status is case-mixed in this table ('ACTIVE' + 'active'); match case-insensitively.
-  // One row per handle: product_colors already carries one dominant color per product,
-  // but guard against dupes with DISTINCT ON (handle) keeping the highest-lightness…
-  // actually keep the first — any dominant color for the product is fine.
+  // Gate on LIVE truth: only handles that are active in shopify_products (synced
+  // from live Shopify) survive, so archived/deleted products can't leak a dead
+  // handle (404 link) or a stale image into the index. status is case-mixed
+  // ('ACTIVE' + 'active'); match case-insensitively. shopify_products is one row
+  // per variant, so DISTINCT ON (pc.handle) collapses to one row per product,
+  // keeping the most-recently-synced variant's featured image. The displayed
+  // image prefers the fresh shopify_products.image_url, falling back to the
+  // color-extract's image_url only if the mirror lacks one.
   const q = `
-    SELECT DISTINCT ON (handle)
-      handle, title, vendor, hex, lab_l, lab_a, lab_b, image_url
-    FROM product_colors
-    WHERE lower(status) = 'active'
-      AND lab_l IS NOT NULL AND lab_a IS NOT NULL AND lab_b IS NOT NULL
-      AND handle IS NOT NULL AND handle <> ''
-      AND image_url IS NOT NULL AND image_url <> ''
-      AND hex ~* '^#?[0-9a-f]{6}$'
-    ORDER BY handle, updated_at DESC NULLS LAST
+    SELECT DISTINCT ON (pc.handle)
+      pc.handle, pc.title, pc.vendor, pc.hex, pc.lab_l, pc.lab_a, pc.lab_b,
+      COALESCE(NULLIF(sp.image_url, ''), pc.image_url) AS image_url
+    FROM product_colors pc
+    JOIN shopify_products sp ON sp.handle = pc.handle
+    WHERE lower(sp.status) = 'active'
+      -- Must be PUBLISHED to the Online Store, not merely active in admin: an
+      -- active-but-unpublished product 404s on the storefront, so its /products/
+      -- <handle> link would be dead. online_store_published = live-visible.
+      AND sp.online_store_published = true
+      AND pc.lab_l IS NOT NULL AND pc.lab_a IS NOT NULL AND pc.lab_b IS NOT NULL
+      AND pc.handle IS NOT NULL AND pc.handle <> ''
+      AND COALESCE(NULLIF(sp.image_url, ''), pc.image_url) IS NOT NULL
+      AND COALESCE(NULLIF(sp.image_url, ''), pc.image_url) <> ''
+      AND pc.hex ~* '^#?[0-9a-f]{6}$'
+    ORDER BY pc.handle, sp.updated_at_shopify DESC NULLS LAST
   `;
   const { rows } = await pool.query(q);
   await pool.end();

← dd6e416 chore: session close snapshot (visual-search)  ·  back to Dw Photo Capture  ·  deps: add pg so build-color-index runs standalone (needed fo 91ce171 →