[object Object]

← back to Designerwallcoverings

google-channel unpublish: add tag-scoped path for shared-vendor showroom lines (TK-11307)

01c73ed80df87e56e908e160beb809efa69c62e7 · 2026-09-08 15:46:32 -0700 · steve

New prep-showroom-unpublish-tag.mjs selects status:active tag:'Showroom' products
actually published to the Google & YouTube channel (re-affirmed per-product via
hasShowroomTag), writing the same-format CSV. apply-unpublish.mjs gains a
--showroom-product-only HARD guard that LIVE-fetches each row's vendor+tags and
requires isShowroomProduct before any channel unpublish — the tag-based analogue of
--showroom-only, so the MDC unpublish cannot mis-fire against a sellable product.
Read-only prep verified (0 rows today — tag not yet applied). Nothing fired.

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

Files touched

Diff

commit 01c73ed80df87e56e908e160beb809efa69c62e7
Author: steve <steve@designerwallcoverings.com>
Date:   Tue Sep 8 15:46:32 2026 -0700

    google-channel unpublish: add tag-scoped path for shared-vendor showroom lines (TK-11307)
    
    New prep-showroom-unpublish-tag.mjs selects status:active tag:'Showroom' products
    actually published to the Google & YouTube channel (re-affirmed per-product via
    hasShowroomTag), writing the same-format CSV. apply-unpublish.mjs gains a
    --showroom-product-only HARD guard that LIVE-fetches each row's vendor+tags and
    requires isShowroomProduct before any channel unpublish — the tag-based analogue of
    --showroom-only, so the MDC unpublish cannot mis-fire against a sellable product.
    Read-only prep verified (0 rows today — tag not yet applied). Nothing fired.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01JDeaFL4eeREBZX79tc4cnr
---
 scripts/google-feed/apply-unpublish.mjs            | 31 ++++++-
 .../google-feed/prep-showroom-unpublish-tag.mjs    | 98 ++++++++++++++++++++++
 2 files changed, 127 insertions(+), 2 deletions(-)

diff --git a/scripts/google-feed/apply-unpublish.mjs b/scripts/google-feed/apply-unpublish.mjs
index 877f23c..b754a6c 100644
--- a/scripts/google-feed/apply-unpublish.mjs
+++ b/scripts/google-feed/apply-unpublish.mjs
@@ -24,7 +24,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { createRequire } from 'node:module';
 const require = createRequire(import.meta.url);
-const { isShowroomVendor } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
+const { isShowroomVendor, isShowroomProduct } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
 
 const GOOGLE_PUBLICATION = 'gid://shopify/Publication/29646651457'; // Google & YouTube
 const SHOP = 'designer-laboratory-sandbox.myshopify.com';
@@ -34,6 +34,11 @@ const APPLY = args.apply === true && args['i-am-steve'] === true;
 const REASON = args.reason || null;
 const LIST = typeof args.list === 'string' ? args.list : 'unpublish-list.csv';
 const SHOWROOM_ONLY = args['showroom-only'] === true;
+// TK-11307: tag-based guard for a SHARED-vendor showroom line (MDC under 'Phillipe Romano').
+// The vendor guard (--showroom-only) can't be used because the vendor is not a showroom
+// vendor. This guard LIVE-fetches each row's vendor+tags and requires isShowroomProduct
+// (vendor-on-list OR carries the 'Showroom' tag) — never trusts the CSV alone.
+const SHOWROOM_PRODUCT_ONLY = args['showroom-product-only'] === true;
 const LIMIT = args.limit ? parseInt(args.limit,10) : Infinity;
 
 const TOKEN = (fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1]?.trim();
@@ -67,6 +72,28 @@ if (SHOWROOM_ONLY) {
     process.exit(1);
   }
 }
+// HARD SHOWROOM-PRODUCT GUARD (TK-11307) — live-verify EVERY row is a showroom product
+// (vendor-on-list OR carries the 'Showroom' tag) before allowing a channel unpublish.
+if (SHOWROOM_PRODUCT_ONLY) {
+  const TQ = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id vendor tags } } }`;
+  const bad = [];
+  for (let i = 0; i < allRows.length; i += 100) {
+    const batch = allRows.slice(i, i + 100);
+    const ids = batch.map(r => `gid://shopify/Product/${r.id}`);
+    const d = await gql(TQ, { ids });
+    const byId = new Map((d.nodes || []).filter(Boolean).map(n => [n.id.split('/').pop(), n]));
+    for (const r of batch) {
+      const n = byId.get(r.id);
+      if (!n || !isShowroomProduct({ vendor: n.vendor, tags: n.tags })) bad.push(r.id + (n ? `(${n.vendor})` : '(missing)'));
+    }
+  }
+  if (bad.length) {
+    console.error(`\n⛔ ABORT: --showroom-product-only set but ${bad.length}/${allRows.length} rows are NOT showroom products (no 'Showroom' tag and vendor not on list).`);
+    console.error(`   e.g. ${bad.slice(0,3).join(', ')}`);
+    console.error(`   This guard prevents unpublishing a sellable product. Re-run prep-showroom-unpublish-tag.mjs to rebuild the list.`);
+    process.exit(1);
+  }
+}
 const rows = allRows
   .filter(x => !REASON || x.reasonClass === REASON)
   .slice(0, LIMIT);
@@ -77,7 +104,7 @@ console.log(`target publication: Google & YouTube (${GOOGLE_PUBLICATION})`);
 console.log(`candidates: ${rows.length}${REASON ? ` (reason=${REASON})` : (SHOWROOM_ONLY ? ' (showroom-scoped set)' : ' (entire excluded set)')}`);
 if (!APPLY) {
   console.log('\nDRY-RUN: nothing will be unpublished. To execute (Steve only):');
-  console.log(`  node apply-unpublish.mjs --apply --i-am-steve --list=${LIST}${SHOWROOM_ONLY?' --showroom-only':''}${REASON?` --reason=${REASON}`:''}`);
+  console.log(`  node apply-unpublish.mjs --apply --i-am-steve --list=${LIST}${SHOWROOM_ONLY?' --showroom-only':''}${SHOWROOM_PRODUCT_ONLY?' --showroom-product-only':''}${REASON?` --reason=${REASON}`:''}`);
   process.exit(0);
 }
 
diff --git a/scripts/google-feed/prep-showroom-unpublish-tag.mjs b/scripts/google-feed/prep-showroom-unpublish-tag.mjs
new file mode 100644
index 0000000..dce8b08
--- /dev/null
+++ b/scripts/google-feed/prep-showroom-unpublish-tag.mjs
@@ -0,0 +1,98 @@
+#!/usr/bin/env node
+/**
+ * prep-showroom-unpublish-tag.mjs — build a TAG-SCOPED unpublish list for the
+ * Google & YouTube sales channel, for a SHARED-vendor showroom line. (TK-11307)
+ *
+ * WHY A SEPARATE TAG PATH: prep-showroom-unpublish.mjs is VENDOR-scoped
+ * (isShowroomVendor + `vendor:'X'` query). MDC lives under the SHARED private label
+ * "Phillipe Romano", which ALSO carries sellable lines — so it can only be selected by
+ * its product-level "Showroom" tag, never by vendor. This tool queries
+ * `status:active AND tag:'Showroom'`, re-affirms each product actually carries the tag
+ * (hasShowroomTag), keeps only those actually published to the Google channel, and
+ * writes a CSV in the SAME format apply-unpublish.mjs consumes.
+ *
+ * READ-ONLY against Shopify (query only). Writes one local CSV. Nothing is unpublished
+ * here — apply-unpublish.mjs (Steve-gated) does that with --showroom-product-only.
+ *
+ * OUTPUT: data/google-feed/unpublish-list-showroom-tag.csv
+ *   header: id,handle,vendor,reason_class,reasons,admin_link
+ *
+ * USAGE: node prep-showroom-unpublish-tag.mjs [--tag=Showroom]
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+const require = createRequire(import.meta.url);
+const { hasShowroomTag } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
+
+const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; }));
+const TAG = typeof args.tag === 'string' ? args.tag : 'Showroom';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const VER = '2024-10';
+const STORE = 'designer-laboratory-sandbox';
+const GOOGLE_PUBLICATION = 'gid://shopify/Publication/29646651457'; // Google & YouTube
+const REASON_CLASS = 'showroom_only_addressable_not_discoverable';
+const adminLink = id => `https://admin.shopify.com/store/${STORE}/products/${id}`;
+
+const TOKEN = (fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
+  .match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1]?.trim();
+if (!TOKEN) { console.error('no token'); process.exit(1); }
+const ENDPOINT = `https://${SHOP}/admin/api/${VER}/graphql.json`;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+async function gql(query, variables) {
+  for (let a = 0; a < 8; a++) {
+    let j;
+    try {
+      const r = await fetch(ENDPOINT, { method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables }) });
+      j = await r.json();
+    } catch (e) { await sleep(1500 * (a + 1)); continue; }
+    if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; } throw new Error(JSON.stringify(j.errors)); }
+    const t = j.extensions?.cost?.throttleStatus;
+    if (t && t.currentlyAvailable < 400) await sleep(1200);
+    return j.data;
+  }
+  throw new Error('retries');
+}
+const csvEsc = s => `"${String(s == null ? '' : s).replace(/"/g, '""')}"`;
+
+const Q = `query($cursor:String,$q:String!){
+  products(first:100, after:$cursor, query:$q){
+    pageInfo{ hasNextPage endCursor }
+    nodes{ id handle vendor tags onGoogle:publishedOnPublication(publicationId:"${GOOGLE_PUBLICATION}") }
+  }
+}`;
+
+(async () => {
+  const DIR = path.join(process.cwd(), 'data', 'google-feed');
+  fs.mkdirSync(DIR, { recursive: true });
+  const q = `status:active tag:'${String(TAG).replace(/'/g, "\\'")}'`;
+  const rows = []; let cursor = null, has = true, seen = 0, onCh = 0, offCh = 0;
+  while (has) {
+    const d = await gql(Q, { cursor, q });
+    for (const p of d.products.nodes) {
+      seen++;
+      // Belt-and-suspenders: re-affirm the tag from the returned tags array, never trust the query alone.
+      if (!hasShowroomTag(p.tags)) continue;
+      if (!p.onGoogle) { offCh++; continue; } // only products actually on the paid channel
+      onCh++;
+      const id = p.id.split('/').pop();
+      rows.push([id, csvEsc(p.handle), csvEsc(p.vendor), REASON_CLASS, csvEsc(REASON_CLASS), adminLink(id)].join(','));
+    }
+    has = d.products.pageInfo.hasNextPage;
+    cursor = d.products.pageInfo.endCursor;
+  }
+  const header = 'id,handle,vendor,reason_class,reasons,admin_link';
+  const out = path.join(DIR, 'unpublish-list-showroom-tag.csv');
+  fs.writeFileSync(out, header + '\n' + rows.join('\n') + (rows.length ? '\n' : ''));
+
+  console.log('TAG-SCOPED showroom unpublish list (Google & YouTube channel only)');
+  console.log(`  tag: '${TAG}'`);
+  console.log(`  active tag:'${TAG}' seen: ${seen} | on Google channel: ${onCh} | already off channel: ${offCh}`);
+  console.log('  TOTAL to unpublish:', rows.length);
+  console.log('  wrote:', out);
+  console.log('\n  Fire (Steve only):');
+  console.log('    node apply-unpublish.mjs --apply --i-am-steve --list=unpublish-list-showroom-tag.csv --showroom-product-only');
+})();

← da49390 theme (source): hide showroom-TAGGED products from browse/se  ·  back to Designerwallcoverings  ·  auto-data-snapshot: 2026-09-08T16:14:09 (2 data files) — scr 42afab5 →