[object Object]

← back to Designerwallcoverings

TK-11186: A2 GMC-channel unpublisher (PJ-scoped, publication restore-map, reversible) + shop showroom_vendors metafield setter

e45ffc0d5f26e2e570d1d64538cba08570a09198 · 2026-09-03 12:02:03 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXf9gMQQWRfNex6EestZhp

Files touched

Diff

commit e45ffc0d5f26e2e570d1d64538cba08570a09198
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 3 12:02:03 2026 -0700

    TK-11186: A2 GMC-channel unpublisher (PJ-scoped, publication restore-map, reversible) + shop showroom_vendors metafield setter
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01YXf9gMQQWRfNex6EestZhp
---
 .../a2-gmc-channel-unpublish.mjs                   | 170 +++++++++++++++++++++
 .../set-showroom-metafield.mjs                     |  30 ++++
 2 files changed, 200 insertions(+)

diff --git a/scripts/tk11186-showroom-hide/a2-gmc-channel-unpublish.mjs b/scripts/tk11186-showroom-hide/a2-gmc-channel-unpublish.mjs
new file mode 100644
index 0000000..9dfc8af
--- /dev/null
+++ b/scripts/tk11186-showroom-hide/a2-gmc-channel-unpublish.mjs
@@ -0,0 +1,170 @@
+#!/usr/bin/env node
+/**
+ * TK-11186 STEP A2 — UNPUBLISH all active showroom-only (Phillip Jeffries) products
+ * from the Google & YouTube sales channel (Publication 29646651457 → MC 146735262),
+ * so a showroom-only line stops advertising on PAID Google Shopping. Durable removal:
+ * a feed/offer delete alone would NOT stick (Shopify re-syncs the channel) — the fix
+ * is publishableUnpublish on the channel itself.
+ *
+ * SCOPE — vendor Phillip Jeffries (read LIVE from the canonical showroom list, never
+ *   hardcoded) on the GOOGLE publication ONLY. Never touches Online Store or any other
+ *   channel, never archives/deletes, never changes prices/tags.
+ *
+ * REVERSIBLE (tk11061 pattern): the restore-map records the EXACT set of products that
+ *   are currently PUBLISHED on the Google channel (wasPublished:true). Undo = re-publish
+ *   only those via `--rollback`. Products already unpublished get no restore entry (so
+ *   rollback never publishes something that wasn't published).
+ *
+ * MIRROR NOTE: this is a sales-CHANNEL publish-state write, not a catalog-column write —
+ *   the dw_unified mirror has no per-publication publish-state column, so there is no
+ *   "mirror-first" row to write; the restore-map IS the reversibility record.
+ *
+ * DEFAULT = --dry-run. --apply executes. --rollback re-publishes from the restore-map.
+ * Ledgers each batch to ~/.claude/yolo-queue/executed-reversible/ledger.jsonl. Cost: $0.
+ *
+ * Usage:
+ *   node a2-gmc-channel-unpublish.mjs                    # DRY-RUN: build+validate restore-map, no writes
+ *   node a2-gmc-channel-unpublish.mjs --apply            # unpublish PJ from Google channel
+ *   node a2-gmc-channel-unpublish.mjs --rollback --apply --map out/a2-restore-map-latest.json
+ *   flags: --batch N (250) --conc N (5) --gap MS (90000)
+ */
+import { gql } from '../lib/shopify.mjs';
+import { createRequire } from 'node:module';
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const require = createRequire(import.meta.url);
+const { showroomVendors } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const OUT_DIR = path.join(__dirname, 'out');
+fs.mkdirSync(OUT_DIR, { recursive: true });
+const LOG_EXEC = process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs';
+
+const GOOGLE_PUB = 'gid://shopify/Publication/29646651457'; // Google & YouTube -> MC 146735262
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const ROLLBACK = args.includes('--rollback');
+const argV = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
+const argN = (k, d) => { const i = args.indexOf(k); return i >= 0 ? parseInt(args[i + 1], 10) : d; };
+const BATCH = argN('--batch', 250), CONC = argN('--conc', 5), GAP = argN('--gap', 90000);
+const MAP_PATH = argV('--map', path.join(OUT_DIR, 'a2-restore-map-latest.json'));
+
+const vendors = showroomVendors();
+if (!vendors.length) { console.error('showroom list empty — abort'); process.exit(1); }
+const vendorClause = '(' + vendors.map(v => `vendor:'${v.replace(/'/g, "\\'")}'`).join(' OR ') + ')';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const stamp = () => new Date().toISOString();
+function ledger(rec) { try { execFileSync('node', [LOG_EXEC], { input: JSON.stringify(rec) }); } catch (e) { console.error('  [ledger WARN]', e.message); } }
+
+// ---- read live PJ + Google-channel publish state ----
+async function scanPublishState() {
+  const rows = []; let cur = null;
+  const Q = `query($q:String!,$c:String,$pub:ID!){
+    products(first:100, query:$q, after:$c){
+      pageInfo{ hasNextPage endCursor }
+      nodes{ id handle vendor status publishedOnPublication(publicationId:$pub) }
+    }
+  }`;
+  while (true) {
+    const d = await gql(Q, { q: `${vendorClause} AND status:active`, c: cur, pub: GOOGLE_PUB });
+    if (d?.__err || !d?.products) throw new Error('scan failed: ' + JSON.stringify(d).slice(0, 200));
+    for (const n of d.products.nodes) rows.push({ id: n.id, handle: n.handle, vendor: n.vendor, wasPublished: !!n.publishedOnPublication });
+    if (!d.products.pageInfo.hasNextPage) break;
+    cur = d.products.pageInfo.endCursor;
+  }
+  return rows;
+}
+
+async function unpublishOne(id) {
+  const d = await gql(`mutation($id:ID!,$pubs:[PublicationInput!]!){ publishableUnpublish(id:$id, input:$pubs){ userErrors{ field message } } }`,
+    { id, pubs: [{ publicationId: GOOGLE_PUB }] });
+  if (d?.__err) throw new Error('gql ' + JSON.stringify(d.__err).slice(0, 150));
+  const ue = d?.publishableUnpublish?.userErrors; if (ue && ue.length) throw new Error('userErrors ' + JSON.stringify(ue));
+}
+async function publishOne(id) {
+  const d = await gql(`mutation($id:ID!,$pubs:[PublicationInput!]!){ publishablePublish(id:$id, input:$pubs){ userErrors{ field message } } }`,
+    { id, pubs: [{ publicationId: GOOGLE_PUB }] });
+  if (d?.__err) throw new Error('gql ' + JSON.stringify(d.__err).slice(0, 150));
+  const ue = d?.publishablePublish?.userErrors; if (ue && ue.length) throw new Error('userErrors ' + JSON.stringify(ue));
+}
+
+async function runBatched(ids, fn, label, undoNote, verifyNote) {
+  let done = 0, failed = 0;
+  for (let b = 0; b < ids.length; b += BATCH) {
+    const batch = ids.slice(b, b + BATCH);
+    for (let i = 0; i < batch.length; i += CONC) {
+      const res = await Promise.allSettled(batch.slice(i, i + CONC).map(fn));
+      res.forEach(r => r.status === 'fulfilled' ? done++ : (failed++, failed <= 10 && console.error(`  [${label} FAIL]`, r.reason?.message)));
+    }
+    console.log(`  ${label} progress: ${Math.min(b + BATCH, ids.length)}/${ids.length} (ok ${done}, fail ${failed})`);
+    ledger({ agent: process.env.TK_AGENT || 'vp-dw-commerce', ticket: 'TK-11186',
+      action: `STEP A2 ${label} Google channel (pub 29646651457) batch [${b}..${Math.min(b + BATCH, ids.length)}) — ok ${done} fail ${failed}`,
+      blast_radius: batch.length, undo_cmd: undoNote, verify: verifyNote });
+    if (b + BATCH < ids.length) { console.log(`  ...inter-batch gap ${GAP}ms`); await sleep(GAP); }
+  }
+  return { done, failed };
+}
+
+// ================= ROLLBACK =================
+if (ROLLBACK) {
+  const map = JSON.parse(fs.readFileSync(MAP_PATH, 'utf8'));
+  const ids = map.rows.filter(r => r.wasPublished).map(r => r.id);
+  console.log(`[${stamp()}] A2 ROLLBACK ${APPLY ? 'APPLY' : 'DRY-RUN'} — re-publish ${ids.length} PJ to Google channel from ${MAP_PATH}`);
+  if (!APPLY) { console.log('  (dry-run) re-run with --apply --rollback'); process.exit(0); }
+  const r = await runBatched(ids, publishOne, 'publishablePublish',
+    `re-unpublish via: node a2-gmc-channel-unpublish.mjs --apply`,
+    `PJ published on Google channel back to ${ids.length}`);
+  console.log(`[${stamp()}] ROLLBACK done — republished ok ${r.done} fail ${r.failed}`);
+  process.exit(r.failed ? 1 : 0);
+}
+
+// ================= FORWARD =================
+console.log(`[${stamp()}] TK-11186 STEP A2 — ${APPLY ? 'APPLY' : 'DRY-RUN'} | vendors ${JSON.stringify(vendors)} | pub ${GOOGLE_PUB}`);
+console.log('  scanning live PJ Google-channel publish state...');
+const rows = await scanPublishState();
+const published = rows.filter(r => r.wasPublished);
+console.log(`  active PJ total: ${rows.length} | currently PUBLISHED on Google channel: ${published.length} | already off: ${rows.length - published.length}`);
+
+// write + validate restore-map BEFORE any write
+const map = { ticket: 'TK-11186', step: 'A2', generated_at: stamp(), publication: GOOGLE_PUB, vendors,
+  active_total: rows.length, published_count: published.length, rows };
+const outFile = path.join(OUT_DIR, `a2-restore-map-${stamp().replace(/[:.]/g, '-')}.json`);
+fs.writeFileSync(outFile, JSON.stringify(map, null, 2));
+fs.writeFileSync(path.join(OUT_DIR, 'a2-restore-map-latest.json'), JSON.stringify(map, null, 2));
+console.log(`  restore-map written: ${outFile} (+ a2-restore-map-latest.json)`);
+
+// VALIDATE restore-map: non-empty, all rows PJ, spot-check 2 ids re-queried live
+if (!rows.length) { console.error('  [ABORT] 0 active PJ found — refuse to proceed'); process.exit(1); }
+const allPJ = rows.every(r => vendors.map(v => v.toLowerCase()).includes((r.vendor || '').toLowerCase()));
+if (!allPJ) { console.error('  [ABORT] restore-map contains a non-showroom vendor — scope breach, refuse'); process.exit(1); }
+const spot = published.slice(0, 2);
+for (const s of spot) {
+  const d = await gql(`query($id:ID!,$pub:ID!){ product(id:$id){ vendor publishedOnPublication(publicationId:$pub) } }`, { id: s.id, pub: GOOGLE_PUB });
+  const ok = d?.product?.vendor && vendors.map(v => v.toLowerCase()).includes(d.product.vendor.toLowerCase()) && d.product.publishedOnPublication === true;
+  console.log(`  spot-check ${s.id}: vendor=${d?.product?.vendor} published=${d?.product?.publishedOnPublication} => ${ok ? 'OK' : 'MISMATCH'}`);
+  if (!ok) { console.error('  [ABORT] spot-check mismatch — restore-map not trustworthy, refuse'); process.exit(1); }
+}
+console.log('  restore-map VALIDATED (non-empty, all PJ, spot-checks passed).');
+
+if (!APPLY) {
+  console.log(`\nDRY-RUN — would publishableUnpublish ${published.length} PJ from Google channel (batch ${BATCH}, conc ${CONC}, gap ${GAP}ms).`);
+  console.log('  Re-run with --apply to execute.');
+  process.exit(0);
+}
+
+// ---- APPLY: unpublish only the currently-published set ----
+console.log(`\n[${stamp()}] APPLY — unpublishing ${published.length} PJ from Google channel`);
+const r = await runBatched(published.map(x => x.id), unpublishOne, 'publishableUnpublish',
+  `node ~/Projects/designerwallcoverings/scripts/tk11186-showroom-hide/a2-gmc-channel-unpublish.mjs --rollback --apply --map ${path.join(OUT_DIR, 'a2-restore-map-latest.json')}`,
+  `re-scan publishedOnPublication(29646651457) for PJ => expect 0 published`);
+
+// verify
+await sleep(2000);
+const after = await scanPublishState();
+const stillPub = after.filter(x => x.wasPublished).length;
+console.log(`\n[VERIFY] PJ still published on Google channel: ${stillPub} (expect 0)`);
+console.log(`[${stamp()}] A2 APPLY done — unpublished ok ${r.done} fail ${r.failed}`);
+process.exit((r.failed || stillPub) ? 1 : 0);
diff --git a/scripts/tk11186-showroom-hide/set-showroom-metafield.mjs b/scripts/tk11186-showroom-hide/set-showroom-metafield.mjs
new file mode 100644
index 0000000..fc30e83
--- /dev/null
+++ b/scripts/tk11186-showroom-hide/set-showroom-metafield.mjs
@@ -0,0 +1,30 @@
+#!/usr/bin/env node
+/**
+ * TK-11186 — set shop metafield custom.showroom_vendors from the canonical
+ * showroom-vendors.json, so the (Steve-deployed) theme suppression is data-driven
+ * instead of relying on the snippet's hardcoded fallback. Reversible: prior value
+ * recorded to out/showroom-metafield-restore.json; undo = re-set or delete.
+ * DEFAULT dry-run; --apply writes. $0.
+ */
+import { gql } from '../lib/shopify.mjs';
+import { createRequire } from 'node:module';
+import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
+const require = createRequire(import.meta.url);
+const { showroomVendors } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const APPLY = process.argv.includes('--apply');
+const VALUE = showroomVendors().join(',');            // "Phillip Jeffries"
+const NS = 'custom', KEY = 'showroom_vendors', TYPE = 'single_line_text_field';
+
+const shop = await gql(`{ shop { id } }`);
+const shopId = shop?.shop?.id; if (!shopId) { console.error('no shop id', JSON.stringify(shop)); process.exit(1); }
+const cur = await gql(`query($id:ID!){ shop { metafield(namespace:"${NS}", key:"${KEY}"){ id value type } } }`, { id: shopId });
+const prior = cur?.shop?.metafield || null;
+fs.writeFileSync(path.join(__dirname, 'out', 'showroom-metafield-restore.json'),
+  JSON.stringify({ ticket: 'TK-11186', shopId, namespace: NS, key: KEY, prior_value: prior?.value ?? null, prior_type: prior?.type ?? null, new_value: VALUE }, null, 2));
+console.log(`shop metafield ${NS}.${KEY} — prior: ${prior ? JSON.stringify(prior.value) : '(unset)'} | new: ${JSON.stringify(VALUE)}`);
+if (!APPLY) { console.log('DRY-RUN — re-run with --apply'); process.exit(0); }
+const d = await gql(`mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{ id namespace key value } userErrors{ field message } } }`,
+  { mf: [{ ownerId: shopId, namespace: NS, key: KEY, type: TYPE, value: VALUE }] });
+const ue = d?.metafieldsSet?.userErrors; if (d?.__err || (ue && ue.length)) { console.error('ERR', JSON.stringify(d?.__err || ue)); process.exit(1); }
+console.log('SET ok:', JSON.stringify(d.metafieldsSet.metafields[0]));

← 9f550a9 TK-10372: turnkey fix+rollback for final 3 dup-image cross-l  ·  back to Designerwallcoverings  ·  sanderson verify: suppress deleted-tombstone false-positive df3ec4b →