← 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
M shopify/scripts/cadence/activate-gated.jsM shopify/scripts/cadence/cadence-import.js
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 →