[object Object]

← back to Designer Wallcoverings

cadence-import: auto-add newly-published products to Trending Wallcovering Collection 2026 (custom collection 298339205171), gated behind --collections

2e31cf351678b2b897854608912eecd4a9159cb0 · 2026-06-19 15:26:47 -0700 · SteveStudio2

New-Arrivals auto-flow fix (DTD 3/3 Option A). Custom collections don't
auto-populate from tags; membership written explicitly via collectionAddProducts
right after publish, atomic with creation. Prior out-of-band collections-agent
had added 0 members since Jun 12. Live collection write gated behind --collections
(OFF by default) so the scheduled --commit run never auto-writes until approved.

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

Files touched

Diff

commit 2e31cf351678b2b897854608912eecd4a9159cb0
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Fri Jun 19 15:26:47 2026 -0700

    cadence-import: auto-add newly-published products to Trending Wallcovering Collection 2026 (custom collection 298339205171), gated behind --collections
    
    New-Arrivals auto-flow fix (DTD 3/3 Option A). Custom collections don't
    auto-populate from tags; membership written explicitly via collectionAddProducts
    right after publish, atomic with creation. Prior out-of-band collections-agent
    had added 0 members since Jun 12. Live collection write gated behind --collections
    (OFF by default) so the scheduled --commit run never auto-writes until approved.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 shopify/scripts/cadence/cadence-import.js | 96 +++++++++++++++++++++++++++++--
 1 file changed, 91 insertions(+), 5 deletions(-)

diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
index 7486524b..ebe5f92f 100644
--- a/shopify/scripts/cadence/cadence-import.js
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -42,6 +42,7 @@ const VENDORS_PER_SLOT = parseInt(val('--vendors', '10'), 10);
 const SKUS_PER_VENDOR = parseInt(val('--skus', '20'), 10);
 const MAX_PRODUCTS = parseInt(val('--max', '0'), 10);   // 0 = unlimited; total products this run (budget-bound by the hourly runner)
 const ACTIVATE = flag('--activate');                    // auto-activate readiness-passing products to ACTIVE + inventory=2026 (Steve 2026-06-17)
+const ADD_TO_COLLECTIONS = flag('--collections');       // GATED: add newly-published products to the "Trending Wallcovering Collection 2026" custom collection (DTD 3/3 Option A, 2026-06-19). OFF by default so the scheduled --commit run does NOT write to the live collection until Steve approves enabling this flag.
 const TODAY = new Date().toISOString().slice(0, 10);
 // Defensive vendor denylist — even if a vendor is in vendors.js + discount_confirmed, NEVER
 // import these (belt-and-suspenders for sub-brand/registry drift). Today vendors.js already
@@ -250,6 +251,66 @@ function buildInput(vendor, cfg, row, retail, activate) {
 
 const PRODUCTSET = `mutation push($input: ProductSetInput!){productSet(synchronous:true,input:$input){product{id handle status} userErrors{field message}}}`;
 
+// ---- publish-to-sales-channels (the missing step — Steve 2026-06-19) ----
+// status:ACTIVE alone does NOT put a product on the Online Store; it stays
+// invisible to shoppers (published_at=null) until publishablePublish runs against
+// the channel publications. Every product the cadence/activate path made ACTIVE
+// since the --activate flag landed (2026-06-17) was created ACTIVE-but-unpublished.
+// This mirrors the proven rebel-walls-push publishAndStock() pattern.
+const M_PUBLISH = `mutation($id:ID!,$input:[PublicationInput!]!){
+  publishablePublish(id:$id, input:$input){ userErrors{field message} }
+}`;
+let PUBLICATION_IDS = null;   // [{id,name}], loaded once per run
+async function loadPublications() {
+  if (PUBLICATION_IDS) return PUBLICATION_IDS;
+  const r = await gqlRetry(`{publications(first:50){edges{node{id name}}}}`, {});
+  PUBLICATION_IDS = (r.json?.data?.publications?.edges || []).map(e => e.node);
+  return PUBLICATION_IDS;
+}
+// Publish an ACTIVE product to ALL channel publications (Online Store first).
+// "already published" userErrors are benign (idempotent re-runs); real errors throw.
+async function publishToChannels(productId) {
+  const pubs = await loadPublications();
+  if (!pubs.length) return { published: false, why: 'no-publications-scope' };
+  const input = pubs.map(p => ({ publicationId: p.id }));
+  const r = await gqlRetry(M_PUBLISH, { id: productId, input });
+  const ue = (r.json?.data?.publishablePublish?.userErrors) || [];
+  const real = ue.filter(e => !/already|cannot be published to itself/i.test(e.message || ''));
+  if (real.length) return { published: false, errors: real };
+  return { published: true, channels: pubs.length };
+}
+
+// ---- auto-membership: "Trending Wallcovering Collection 2026" (custom collection) ----
+// THE NEW-ARRIVALS AUTO-FLOW FIX (DTD 3/3 Option A, 2026-06-19). A CUSTOM collection does
+// NOT auto-populate from tags — membership must be written explicitly with collectionAddProducts.
+// cadence-import.js is the single chokepoint where every product is created+activated+published,
+// so we add membership here, atomic with the publish step. The prior out-of-band mechanism (the
+// Kamatera collections-agent / new-arrivals-rotator) had added ZERO members since Jun 12.
+// Verified read-only: 298339205171 is a CUSTOM collection (ruleSet=null) → collectionAddProducts
+// is the correct mutation (it is invalid against a smart/rule-based collection).
+// GATED behind --collections: even in --commit mode, the live collection write is SKIPPED unless
+// --collections is also passed, so committing this code never auto-writes to the storefront.
+const TRENDING_COLLECTION_ID = 'gid://shopify/Collection/298339205171';
+const M_COLLECTION_ADD = `mutation($id:ID!,$productIds:[ID!]!){
+  collectionAddProducts(id:$id, productIds:$productIds){ userErrors{field message} }
+}`;
+// Add an array of product gids to the Trending collection. "already in collection" userErrors
+// are benign (idempotent re-runs); real errors are surfaced. Batches of 50 (mutation cost).
+async function addToTrendingCollection(productIds) {
+  if (!productIds.length) return { added: 0, errors: [] };
+  const errors = []; let added = 0;
+  for (let i = 0; i < productIds.length; i += 50) {
+    const batch = productIds.slice(i, i + 50);
+    const r = await gqlRetry(M_COLLECTION_ADD, { id: TRENDING_COLLECTION_ID, productIds: batch });
+    const ue = r.json?.data?.collectionAddProducts?.userErrors || [];
+    const real = ue.filter(e => !/already|exists|in the collection/i.test(e.message || ''));
+    if (real.length) { errors.push(...real); }
+    else if (!r.json?.data && r.status !== 200) { errors.push({ message: (r.raw || `status ${r.status}`).slice(0, 120) }); }
+    else { added += batch.length; }
+  }
+  return { added, errors };
+}
+
 // ---- restore map (one batch_id per slot → a bad hour is selectable + revertible) ----
 const BATCH_ID = `upload-${SLOT}-${new Date().toISOString().replace(/[:.]/g,'-')}`;
 const RESTORE_FILE = path.join(DATADIR, `upload-restore-${TODAY}.jsonl`);
@@ -336,8 +397,16 @@ async function createProduct(table, row, payload) {
   const num = String(pid).replace(/.*\//,'');
   psql(`UPDATE ${table} SET shopify_product_id='${num}' WHERE dw_sku='${row.dw_sku.replace(/'/g,"''")}';`);
   const activated = !!payload.willActivate;
-  appendRestore({ table, mfr_sku: row.mfr_sku, dw_sku: row.dw_sku, shopify_product_id: num, action: 'created', activated, status: activated ? 'ACTIVE' : 'DRAFT' });
-  return { ok:true, pid, activated, skus: activated ? [row.dw_sku, `${row.dw_sku}-Sample`] : [] };
+  // PUBLISH STEP (the fix): an ACTIVE product is invisible on the storefront until it's
+  // published to the Online Store channel. Publish here so --activate truly means "live".
+  let published = false;
+  if (activated) {
+    try { const pub = await publishToChannels(pid); published = !!pub.published;
+      if (!published) console.log(`  ⚠ ${row.dw_sku} ACTIVE but publish failed: ${JSON.stringify(pub.errors||pub.why).slice(0,120)}`);
+    } catch (e) { console.log(`  ⚠ ${row.dw_sku} publish error: ${String(e.message).slice(0,120)}`); }
+  }
+  appendRestore({ table, mfr_sku: row.mfr_sku, dw_sku: row.dw_sku, shopify_product_id: num, action: 'created', activated, published, status: activated ? 'ACTIVE' : 'DRAFT' });
+  return { ok:true, pid, activated, published, skus: activated ? [row.dw_sku, `${row.dw_sku}-Sample`] : [] };
 }
 
 // ---- main ----
@@ -359,7 +428,7 @@ async function createProduct(table, row, payload) {
   for (let i=0;i<Math.min(VENDORS_PER_SLOT, ready.length);i++) picked.push(ready[(start+i)%ready.length]);
   console.log(`\n=== ${COMMIT?'LIVE COMMIT':'DRY-RUN'} — slot ${SLOT}: ${picked.length} vendor(s) × up to ${SKUS_PER_VENDOR} SKUs ===`);
 
-  const plan = []; let created=0, failed=0, linked=0; const activatedSkus=[]; let hitMax=false, hitCap=false;
+  const plan = []; let created=0, failed=0, linked=0; const activatedSkus=[]; const publishedProductIds=[]; let hitMax=false, hitCap=false;
   if (MAX_PRODUCTS) console.log(`(--max ${MAX_PRODUCTS} products this run)${ACTIVATE?'  --activate: readiness-passing → ACTIVE + inventory 2026':''}`);
   for (const r of picked) {
     if (hitMax || hitCap) break;
@@ -374,7 +443,7 @@ async function createProduct(table, row, payload) {
       if (!COMMIT) { console.log(`  · ${row.dw_sku}  cost $${row.cost.toFixed(2)} → roll $${retail}  sample $${SAMPLE_PRICE}  ${ACTIVATE?(payload.willActivate?'[→ACTIVE]':'[draft:not-ready]'):''}  | ${payload.title.slice(0,48)}`); continue; }
       const res = await createProduct(r.cfg.table, row, payload);
       if (res.ok && res.action === 'linked-existing') { linked++; console.log(`  ↪ ${row.dw_sku} already on Shopify (${res.status}) → backfilled link ${String(res.pid).replace(/.*\//,'')}, skipped create (dedup guard)`); }
-      else if (res.ok) { created++; if (res.activated && res.skus) activatedSkus.push(...res.skus); if(created%10===0) process.stdout.write(`\r  created ${created}…`); }
+      else if (res.ok) { created++; if (res.activated && res.skus) activatedSkus.push(...res.skus); if (res.published && res.pid) publishedProductIds.push(res.pid); if(created%10===0) process.stdout.write(`\r  created ${created}…`); }
       else { failed++; console.log(`  ✗ ${row.dw_sku} ${JSON.stringify(res.err).slice(0,140)}`); }
       if (res.stop) { console.log(`\n⛔ DAILY_VARIANT_LIMIT reached — stopping (resets in ~24h). created ${created} this run.`); hitCap=true; break; }
       if (MAX_PRODUCTS && (created + linked) >= MAX_PRODUCTS) { console.log(`\n· --max ${MAX_PRODUCTS} reached — stopping this slot (resumes next slot via cursor).`); hitMax=true; break; }
@@ -386,6 +455,19 @@ async function createProduct(table, row, payload) {
     try { const inv = await setInventory2026(activatedSkus); console.log(`  inventory set: ${inv.set} pairs, mapped ${inv.mapped}, errors ${inv.errors.length}`); if (inv.errors.length) console.log('  inv errors:', JSON.stringify(inv.errors).slice(0,200)); }
     catch (e) { console.warn('  inventory-set failed (products are ACTIVE but inventory not 2026 — re-run inventory-set-2026.js):', e.message); }
   }
+  // auto-membership into "Trending Wallcovering Collection 2026" (the New-Arrivals auto-flow fix).
+  // Only NEWLY-PUBLISHED products join (status ACTIVE + published to Online Store). GATED behind
+  // --collections: the live collection write is SKIPPED unless --collections is passed.
+  if (COMMIT && publishedProductIds.length) {
+    if (ADD_TO_COLLECTIONS) {
+      console.log(`\nadding ${publishedProductIds.length} newly-published product(s) to "Trending Wallcovering Collection 2026" (298339205171)…`);
+      try { const c = await addToTrendingCollection(publishedProductIds); console.log(`  collection add: ${c.added} added, errors ${c.errors.length}`); if (c.errors.length) console.log('  collection errors:', JSON.stringify(c.errors).slice(0,200)); }
+      catch (e) { console.warn('  collection-add failed (products are published but NOT yet in the Trending collection — re-run with --collections):', e.message); }
+    } else {
+      console.log(`\n⚠ ${publishedProductIds.length} newly-published product(s) were NOT added to the Trending collection (298339205171).`);
+      console.log(`  → live collection write is GATED: pass --collections to enable it (see pending-approval / vp-dw-commerce sign-off).`);
+    }
+  }
   // advance rotation
   saveCursor({ idx: (start + picked.length) % ready.length, lastSlot: SLOT, lastRun: TODAY });
   const planFile = path.join(DATADIR, `cadence-plan-${SLOT}-${TODAY}.json`);
@@ -393,7 +475,11 @@ async function createProduct(table, row, payload) {
   const activatedN = activatedSkus.length / 2;
   console.log(`\n\n${COMMIT?`CREATED ${created} (${ACTIVATE?activatedN+' ACTIVE / '+(created-activatedN)+' DRAFT':'all DRAFT'}), linked-existing ${linked} (dedup guard), failed ${failed}`:`DRY-RUN planned ${plan.length} products${ACTIVATE?` (${plan.filter(p=>p.willActivate).length} would activate)`:''}`} → ${planFile}`);
   if (COMMIT) console.log(`restore map: ${RESTORE_FILE} (batch ${BATCH_ID})`);
-  if (!COMMIT) console.log('Re-run with --commit to create these on Shopify (DRAFT status, dw_unified logged first).');
+  if (!COMMIT) {
+    console.log('Re-run with --commit to create these on Shopify (DRAFT status, dw_unified logged first).');
+    const wouldPublish = plan.filter(p => p.willActivate).length;
+    if (wouldPublish) console.log(`Of those, ${wouldPublish} would publish → with --commit --activate --collections they would auto-join "Trending Wallcovering Collection 2026" (298339205171). The --collections live write is GATED (pending-approval / vp-dw-commerce).`);
+  }
 
   // refresh the CNCP "Price Coverage" goal panel now that new cost may have landed.
   // Read-mostly + isolated to its own tables; never let it break the import.

← 6883b74f cadence: enable --collections so hourly 18/hr new SKUs auto-  ·  back to Designer Wallcoverings  ·  Add gated Trending-collection backfill apply script (1040 ne ffb5c80a →