[object Object]

← back to Designer Wallcoverings

Activation gate: enforce specs+description+ALL-vendor-images before ACTIVE (single validateBeforeActivate, both chokepoints)

6396e1939d1172d797a353b27032167499539fc5 · 2026-06-20 10:06:07 -0700 · SteveStudio2

Extend the standing 'NEVER Activate Without Width AND Image' rule to a fuller
gate per Steve 2026-06-20. New shared validator lib/validate-before-activate.js:
- SPECS: global.width hard-required + length/repeat/material/unit_of_measure
  required where the vendor row provides them
- DESCRIPTION: non-empty, not placeholder/Unknown/Page-Not-Found/404/error,
  not bare legal boilerplate
- IMAGES: >=1 product image AND all vendor all_images attached (basename match)
- GUARDS: no banned 'Wallpaper', no 'Unknown' in title, sample variant present
On fail -> keep DRAFT + tag Needs-Specs / Needs-Description / Needs-Image.

Wired into BOTH activation chokepoints:
- cadence-import.js (create-time): pulls all_images, attaches ALL vendor images,
  routes the inline readiness check through the validator, carries Needs-* tags.
- activate-gated.js (DRAFT->ACTIVE promote): fetches live images/metafields/body/
  variants + vendor all_images, gate-blocks incomplete drafts (audit-logged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 6396e1939d1172d797a353b27032167499539fc5
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Sat Jun 20 10:06:07 2026 -0700

    Activation gate: enforce specs+description+ALL-vendor-images before ACTIVE (single validateBeforeActivate, both chokepoints)
    
    Extend the standing 'NEVER Activate Without Width AND Image' rule to a fuller
    gate per Steve 2026-06-20. New shared validator lib/validate-before-activate.js:
    - SPECS: global.width hard-required + length/repeat/material/unit_of_measure
      required where the vendor row provides them
    - DESCRIPTION: non-empty, not placeholder/Unknown/Page-Not-Found/404/error,
      not bare legal boilerplate
    - IMAGES: >=1 product image AND all vendor all_images attached (basename match)
    - GUARDS: no banned 'Wallpaper', no 'Unknown' in title, sample variant present
    On fail -> keep DRAFT + tag Needs-Specs / Needs-Description / Needs-Image.
    
    Wired into BOTH activation chokepoints:
    - cadence-import.js (create-time): pulls all_images, attaches ALL vendor images,
      routes the inline readiness check through the validator, carries Needs-* tags.
    - activate-gated.js (DRAFT->ACTIVE promote): fetches live images/metafields/body/
      variants + vendor all_images, gate-blocks incomplete drafts (audit-logged).
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 shopify/scripts/cadence/activate-gated.js | 61 ++++++++++++++++++++++++++++---
 shopify/scripts/cadence/cadence-import.js | 57 ++++++++++++++++++++++-------
 2 files changed, 100 insertions(+), 18 deletions(-)

diff --git a/shopify/scripts/cadence/activate-gated.js b/shopify/scripts/cadence/activate-gated.js
index 48800991..48e049c7 100644
--- a/shopify/scripts/cadence/activate-gated.js
+++ b/shopify/scripts/cadence/activate-gated.js
@@ -31,6 +31,7 @@ const os = require('os');
 const path = require('path');
 const { execFileSync } = require('child_process');
 const VENDORS = require('./vendors.js');
+const { validateBeforeActivate, toImageList } = require('../lib/validate-before-activate.js'); // single activation gate (Steve 2026-06-20)
 
 const args = process.argv.slice(2);
 const flag = n => args.includes(n);
@@ -72,8 +73,21 @@ async function gqlRetry(q, v) {
 // IMAGE+WIDTH gate from PG (clean source image, has width); the PRICE gate is
 // applied against Shopify's actual per-unit variant price below (the true listed
 // price — robust to per-vendor catalog price-column differences).
+function hasCol(table, col) {
+  const t = String(table).replace(/[^a-z0-9_]/gi,''); const c = String(col).replace(/[^a-z0-9_]/gi,'');
+  try { return (psql(`SELECT 1 FROM information_schema.columns WHERE table_name='${t}' AND column_name='${c}' LIMIT 1;`) || '').trim() === '1'; }
+  catch { return false; }
+}
 function candidates(table, limit) {
-  const sql = `SELECT dw_sku, shopify_product_id
+  // Pull the vendor-side spec + all_images fuel so the activation gate can verify
+  // ALL vendor images + required specs (not just image_url+width). Columns absent
+  // on a legacy table coalesce to '' so the gate still runs.
+  const aiCol  = hasCol(table, 'all_images')     ? `COALESCE(all_images::text,'')`     : `''`;
+  const lenCol = hasCol(table, 'roll_length')    ? `COALESCE(roll_length::text,'')`    : `''`;
+  const repCol = hasCol(table, 'pattern_repeat') ? `COALESCE(pattern_repeat::text,'')` : `''`;
+  const matCol = hasCol(table, 'material')       ? `COALESCE(material::text,'')`       : `''`;
+  const sql = `SELECT dw_sku, shopify_product_id, COALESCE(width::text,''), COALESCE(image_url,''),
+      ${aiCol}, ${lenCol}, ${repCol}, ${matCol}
     FROM ${table}
     WHERE coalesce(shopify_product_id::text,'') <> ''
       AND image_url ~ '^https?://'
@@ -82,10 +96,19 @@ function candidates(table, limit) {
     ORDER BY dw_sku LIMIT ${limit};`;
   const out = psql(sql);
   if (!out) return [];
-  return out.split('\n').map(l => { const [dw_sku, pid] = l.split('\t'); return { dw_sku, pid }; });
+  return out.split('\n').map(l => {
+    const [dw_sku, pid, width, image_url, all_images, roll_length, pattern_repeat, material] = l.split('\t');
+    return { dw_sku, pid, width, image_url, all_images, roll_length, pattern_repeat, material };
+  });
 }
 
-const STATUS_Q = `query($ids:[ID!]!){nodes(ids:$ids){... on Product{id status variants(first:6){nodes{price}}}}}`;
+const STATUS_Q = `query($ids:[ID!]!){nodes(ids:$ids){... on Product{
+  id status title descriptionHtml
+  images(first:50){nodes{url}}
+  variants(first:10){nodes{price sku}}
+  metafields(first:60){nodes{namespace key value}}
+}}}`;
+const mfVal = (mfs, ns, key) => { const m = (mfs||[]).find(x => x.namespace===ns && x.key===key); return m ? m.value : ''; };
 const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){product{id status} userErrors{field message}}}`;
 
 (async () => {
@@ -104,6 +127,7 @@ const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){
     const byGid = new Map(cands.map(c => [`gid://shopify/Product/${String(c.pid).replace(/.*\//,'')}`, c]));
     const ids = [...byGid.keys()];
     const drafts = [];
+    let gateBlocked = 0;
     for (let i=0;i<ids.length;i+=50){
       const r = await gqlRetry(STATUS_Q, { ids: ids.slice(i,i+50) });
       for (const n of (r.json?.data?.nodes || [])) {
@@ -112,10 +136,37 @@ const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){
         if (n.status !== 'DRAFT') { skippedArchived++; continue; } // ARCHIVED → leave
         // PRICE gate on the live product: a real per-unit price > the $4.25 sample
         const hasRealPrice = (n.variants?.nodes || []).some(v => parseFloat(v.price) > 4.25);
-        if (hasRealPrice) drafts.push(n.id); else skippedArchived++;
+        if (!hasRealPrice) { skippedArchived++; continue; }
+        // FULL ACTIVATION GATE (Steve 2026-06-20): specs + real description + ALL
+        // vendor images + title guards + sample variant. Built from the LIVE product
+        // (images/metafields/body) cross-checked against the vendor row's all_images.
+        const c = byGid.get(n.id);
+        const mfs = (n.metafields?.nodes) || [];
+        const liveImgs = (n.images?.nodes || []).map(x => x.url);
+        const gate = validateBeforeActivate({
+          title: n.title, dwSku: (c && c.dw_sku) || '',
+          descriptionHtml: n.descriptionHtml || '',
+          specs: {
+            width:  mfVal(mfs,'global','width') || mfVal(mfs,'custom','width') || (c && c.width) || '',
+            length: mfVal(mfs,'global','length') || (c && c.roll_length) || '',
+            repeat: mfVal(mfs,'global','repeat') || (c && c.pattern_repeat) || '',
+            material: mfVal(mfs,'global','material') || mfVal(mfs,'custom','material') || (c && c.material) || '',
+            unitOfMeasure: mfVal(mfs,'global','unit_of_measure') || '',
+          },
+          vendorSpecs: {
+            width: (c && c.width) || '', length: (c && c.roll_length) || '',
+            repeat: (c && c.pattern_repeat) || '', material: (c && c.material) || '',
+            unitOfMeasure: mfVal(mfs,'global','unit_of_measure') || '',
+          },
+          images: liveImgs,
+          vendorImages: (c && toImageList(c.all_images).length) ? c.all_images : liveImgs,
+          variants: (n.variants?.nodes || []).map(v => ({ sku: v.sku })),
+        });
+        if (gate.ok) drafts.push(n.id);
+        else { gateBlocked++; fs.appendFileSync(AUDIT, JSON.stringify({ ts:new Date().toISOString(), vendor, dw_sku:(c&&c.dw_sku), product_id:n.id, action:'gate-blocked', reasons:gate.reasons, tags:gate.tags }) + '\n'); }
       }
     }
-    console.log(`  ${vendor}: ${cands.length} gate-pass · ${drafts.length} DRAFT to promote · ${alreadyActive} already ACTIVE`);
+    console.log(`  ${vendor}: ${cands.length} gate-pass · ${drafts.length} DRAFT to promote · ${gateBlocked} gate-blocked · ${alreadyActive} already ACTIVE`);
     for (const gid of drafts) {
       const c = byGid.get(gid);
       if (!COMMIT) { console.log(`    · would ACTIVATE ${c.dw_sku}`); continue; }
diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
index 1b00dd64..72f7b352 100644
--- a/shopify/scripts/cadence/cadence-import.js
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -29,6 +29,7 @@ const os = require('os');
 const path = require('path');
 const { execFileSync } = require('child_process');
 const VENDORS = require('./vendors.js');
+const { validateBeforeActivate } = require('../lib/validate-before-activate.js'); // single activation gate (Steve 2026-06-20)
 
 // ---- args ----
 const args = process.argv.slice(2);
@@ -186,15 +187,28 @@ function selectSkus(cfg, limit) {
   const STD = ['dw_sku','mfr_sku','pattern_name','color_name','collection','width','image_url','product_type','material','roll_length','pattern_repeat'];
   const ov = cfg.colOverrides || {};
   const cols = STD.map(c => ov[c] ? `${ov[c]} AS ${c}` : c).join(', ');
+  // all_images (full-page-scrape rule): every vendor image must be attached before
+  // activation. Pulled as a NULLABLE column — absent in a few legacy tables, so
+  // coalesce to '' via to_jsonb of whatever the table has (or '' when missing).
+  const ALLIMG = (cfg.colOverrides && cfg.colOverrides.all_images) || 'all_images';
   // sellExpr (optional): vendor sells at MAP, not cost/0.65/0.85 (e.g. Kravet family).
   const sellSel = cfg.sellExpr ? `, (${cfg.sellExpr})::numeric AS sell` : '';
-  const sql = `SELECT ${cols}, (${cfg.costExpr})::numeric AS cost${sellSel}
+  // Conditionally select all_images only if the column exists on this table — a
+  // few legacy catalogs lack it. Detected once per table (cheap catalog query).
+  let hasAllImg = false;
+  try {
+    const t = String(cfg.table).replace(/[^a-z0-9_]/gi,'');
+    const c = String(ALLIMG).replace(/[^a-z0-9_]/gi,'');
+    hasAllImg = (psql(`SELECT 1 FROM information_schema.columns WHERE table_name='${t}' AND column_name='${c}' LIMIT 1;`) || '').trim() === '1';
+  } catch { hasAllImg = false; }
+  const allImgSel = hasAllImg ? `, (${ALLIMG})::text AS all_images` : '';
+  const sql = `SELECT ${cols}, (${cfg.costExpr})::numeric AS cost${sellSel}${allImgSel}
     FROM ${cfg.table}
     WHERE coalesce(shopify_product_id::text,'')='' AND (${cfg.costExpr})::numeric > 0
     ORDER BY dw_sku LIMIT ${limit};`;
   const out = psql(sql);
   if (!out) return [];
-  const keys = [...STD, 'cost', ...(cfg.sellExpr ? ['sell'] : [])];
+  const keys = [...STD, 'cost', ...(cfg.sellExpr ? ['sell'] : []), ...(hasAllImg ? ['all_images'] : [])];
   return out.split('\n').map(line => { const v=line.split('\t'); const o={}; keys.forEach((k,i)=>o[k]=v[i]); o.cost=parseFloat(o.cost); if(cfg.sellExpr) o.sell=parseFloat(o.sell); return o; });
 }
 
@@ -227,19 +241,30 @@ function buildInput(vendor, cfg, row, retail, activate) {
   m('custom','cost',row.cost.toFixed(2),ND);                 // REAL cost
   m('custom','price_updated_at',TODAY,'date');                // 30-day refresh audit
   m('global','unit_of_measure',`Priced Per ${cfg.soldBy}`,SL);
-  // Auto-activate-to-live readiness gate (Steve 2026-06-17 "auto-activate to live"; description
-  // gate added 2026-06-19 "all new must have description"): a product goes ACTIVE only if it's
-  // complete — real image + width + pattern + a non-empty description (cost is already gated by
-  // costExpr>0 upstream; title is banned-word-clean by construction). Anything incomplete stays
-  // DRAFT (still "uploaded", fixable later) — never a broken product on the storefront.
-  const hasDescription = !!String(intro || '').trim();
-  const ready = !!(row.image_url && String(row.width || '').trim() && pattern && hasDescription);
+  // Auto-activate-to-live readiness gate (Steve 2026-06-17 "auto-activate to live";
+  // description gate 2026-06-19; SPECS+DESC+ALL-VENDOR-IMAGES gate 2026-06-20):
+  // a product goes ACTIVE only when validateBeforeActivate() passes — real image
+  // AND all vendor images, required specs (width hard + length/repeat/material/uom
+  // where the vendor has them), a real (non-placeholder/non-legal) description,
+  // banned-word/Unknown-clean title, and a sample variant. Anything incomplete
+  // stays DRAFT and is TAGGED Needs-Specs / Needs-Description / Needs-Image so it's
+  // fixable later — never a broken product on the storefront.
+  const descriptionHtml = `<p>${esc(intro)}</p><ul>${specs.join('')}</ul>`;
+  const gate = validateBeforeActivate({
+    title, dwSku: row.dw_sku, descriptionHtml,
+    specs:       { width: row.width, length: row.roll_length, repeat: row.pattern_repeat, material: row.material, unitOfMeasure: `Priced Per ${cfg.soldBy}` },
+    vendorSpecs: { width: row.width, length: row.roll_length, repeat: row.pattern_repeat, material: row.material, unitOfMeasure: cfg.soldBy },
+    images:       row.image_url ? [row.image_url] : [],
+    vendorImages: row.all_images || (row.image_url ? [row.image_url] : []),
+    variants: [{ sku: row.dw_sku }, { sku: `${row.dw_sku}-Sample` }],
+  });
+  const ready = gate.ok;
   const willActivate = !!(activate && ready);
   const input = {
     title, handle, vendor, productType: cfg.productType,
-    status: willActivate ? 'ACTIVE' : 'DRAFT',  // DRAFT unless --activate AND readiness passes
-    tags: [vendor, cfg.productType, `Priced Per ${cfg.soldBy}`, ...(row.collection?[`Collection: ${row.collection}`]:[]), ...(color?[color]:[])],
-    descriptionHtml: `<p>${esc(intro)}</p><ul>${specs.join('')}</ul>`,
+    status: willActivate ? 'ACTIVE' : 'DRAFT',  // DRAFT unless --activate AND the full gate passes
+    tags: [vendor, cfg.productType, `Priced Per ${cfg.soldBy}`, ...(row.collection?[`Collection: ${row.collection}`]:[]), ...(color?[color]:[]), ...gate.tags],
+    descriptionHtml,
     metafields: mf,
     productOptions: [{ name:'Title', position:1, values:[{name:cfg.soldBy},{name:'Sample'}] }],
     variants: [
@@ -247,7 +272,13 @@ function buildInput(vendor, cfg, row, retail, activate) {
       { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:true}, inventoryPolicy:'CONTINUE', taxable:true },
     ],
   };
-  if (row.image_url) input.files = [{ originalSource: webImage(row.image_url), contentType:'IMAGE', alt: core }];
+  // Attach ALL vendor images (full-page-scrape rule), primary first, de-duped.
+  const { toImageList } = require('../lib/validate-before-activate.js');
+  const imgUrls = [...new Set([
+    ...(row.image_url ? [row.image_url] : []),
+    ...toImageList(row.all_images),
+  ].map(u => String(u||'').trim()).filter(u => /^https?:\/\//i.test(u)))];
+  if (imgUrls.length) input.files = imgUrls.map(u => ({ originalSource: webImage(u), contentType:'IMAGE', alt: core }));
   return { input, title, retail, willActivate, ready };
 }
 

← c14beea5 Activated viewer: GraphQL feed with essentials audit (priced  ·  back to Designer Wallcoverings  ·  Codify extended activation gate (specs+description+all-image 3f63fd9f →