[object Object]

← back to Dw Contact Us Pages

scripts: assign-template, harden-variants (tracked-first), unpublish-channels — all dry-run default with --rollback

63acba0f305efebbad12e62c42b5c01e1a4821eb · 2026-09-19 10:11:07 -0700 · Claude (TK-11925)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163KBzeE1R39RSbxNmAjbki

Files touched

Diff

commit 63acba0f305efebbad12e62c42b5c01e1a4821eb
Author: Claude (TK-11925) <steve@designerwallcoverings.com>
Date:   Sat Sep 19 10:11:07 2026 -0700

    scripts: assign-template, harden-variants (tracked-first), unpublish-channels — all dry-run default with --rollback
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_0163KBzeE1R39RSbxNmAjbki
---
 scripts/assign-template.mjs    |  59 +++++++++++++++
 scripts/harden-variants.mjs    | 160 +++++++++++++++++++++++++++++++++++++++++
 scripts/unpublish-channels.mjs |  74 +++++++++++++++++++
 3 files changed, 293 insertions(+)

diff --git a/scripts/assign-template.mjs b/scripts/assign-template.mjs
new file mode 100644
index 0000000..74fd17f
--- /dev/null
+++ b/scripts/assign-template.mjs
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+// assign-template.mjs — set templateSuffix="contact-us" on every target product. TK-11925.
+//   node scripts/assign-template.mjs              # dry-run
+//   node scripts/assign-template.mjs --apply
+//   node scripts/assign-template.mjs --rollback --apply
+// Preimage (old templateSuffix, exactly as read) -> data/ledger-template.jsonl
+import { join } from 'node:path';
+import { ROOT, parseArgs, banner, gql, loadTargets, appendJsonl, readJsonl, logReversible } from './lib.mjs';
+
+const a = parseArgs();
+const SUFFIX = 'contact-us';
+const LEDGER = join(ROOT, 'data', 'ledger-template.jsonl');
+banner(a.rollback ? 'assign-template --rollback' : 'assign-template', a.apply);
+
+const M = `
+mutation SetSuffix($input: ProductInput!) {
+  productUpdate(input: $input) { product { id templateSuffix } userErrors { field message } }
+}`;
+
+let work;
+if (a.rollback) {
+  const rows = readJsonl(LEDGER).filter((r) => r.applied);
+  const latest = new Map();
+  for (const r of rows) latest.set(r.productId, r);
+  // Shopify has no "null" for templateSuffix on write: '' clears it, which is how a
+  // product with templateSuffix null reads afterwards. Recorded verbatim either way.
+  work = [...latest.values()].map((r) => ({ id: r.productId, handle: r.handle, to: r.before ?? '' }));
+} else {
+  work = loadTargets()
+    .filter((p) => p.templateSuffix !== SUFFIX)
+    .map((p) => ({ id: p.id, handle: p.handle, to: SUFFIX, before: p.templateSuffix }));
+}
+
+console.log(`plan: ${work.length} productUpdate calls -> templateSuffix ${a.rollback ? '(restored)' : `"${SUFFIX}"`}`);
+if (work.length) {
+  for (const w of work.slice(0, 3)) console.log(`  e.g. ${w.handle}: ${JSON.stringify(w.before ?? '')} -> ${JSON.stringify(w.to)}`);
+  if (work.length > 3) console.log(`  … +${work.length - 3} more`);
+}
+if (!a.apply) { console.log('\nDRY-RUN: nothing was written.'); process.exit(0); }
+
+let ok = 0, fail = 0;
+for (const w of work) {
+  const d = await gql(M, { input: { id: w.id, templateSuffix: w.to } });
+  const errs = d.productUpdate?.userErrors || [];
+  if (errs.length) { fail++; console.error(`  FAIL ${w.handle}: ${JSON.stringify(errs)}`); }
+  else {
+    ok++;
+    if (!a.rollback) appendJsonl(LEDGER, { ts: new Date().toISOString(), productId: w.id, handle: w.handle, before: w.before, after: w.to, applied: true });
+    if (ok % 50 === 0) process.stderr.write(`\r  ${ok}/${work.length}   `);
+  }
+}
+console.log(`\ndone: ${ok} ok, ${fail} failed`);
+if (ok && !a.rollback) logReversible({
+  action: `TK-11925 templateSuffix -> contact-us on ${ok} products (Designers Guild / Ralph Lauren / Christian Lacroix Europe)`,
+  blast: ok,
+  undo: 'cd ~/Projects/dw-contact-us-pages && node scripts/assign-template.mjs --rollback --apply',
+  verify: 'cd ~/Projects/dw-contact-us-pages && node scripts/verify.mjs',
+});
+process.exit(fail ? 1 : 0);
diff --git a/scripts/harden-variants.mjs b/scripts/harden-variants.mjs
new file mode 100644
index 0000000..f2456be
--- /dev/null
+++ b/scripts/harden-variants.mjs
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+// harden-variants.mjs — hide + HARDEN the sellable variants of the contact-us cohort. TK-11925.
+//   node scripts/harden-variants.mjs            # dry-run
+//   node scripts/harden-variants.mjs --apply
+//   node scripts/harden-variants.mjs --rollback --apply
+//
+// For every NON-SAMPLE variant (the $4.25 Sample swatch is NEVER touched):
+//   1. inventoryItemUpdate tracked:true   — REQUIRED first: 598 of the 737 sellable
+//      variants have tracked=false, and inventorySetQuantities fails on an untracked
+//      item. An untracked variant is also ALWAYS available regardless of policy, so
+//      DENY+0 only bites once tracking is on.
+//   2. inventorySetQuantities on_hand = 0 at every location the item stocks (measured:
+//      exactly one location, gid://shopify/Location/5795643504).
+//   3. productVariantsBulkUpdate inventoryPolicy = DENY.
+//
+// PREIMAGE per variant -> data/ledger-variants.jsonl: {inventoryPolicy, tracked,
+// levels:[{locationId, on_hand, available}]}. --rollback replays it in reverse order
+// (policy -> quantities -> tracked:false last) so tracked=false is restored for the 598.
+import { join } from 'node:path';
+import { ROOT, parseArgs, banner, gql, loadTargets, appendJsonl, readJsonl, logReversible, chunk, isSampleVariant } from './lib.mjs';
+
+const a = parseArgs();
+const LEDGER = join(ROOT, 'data', 'ledger-variants.jsonl');
+banner(a.rollback ? 'harden-variants --rollback' : 'harden-variants', a.apply);
+
+const M_TRACK = `mutation T($id: ID!, $input: InventoryItemInput!) {
+  inventoryItemUpdate(id: $id, input: $input) { inventoryItem { id tracked } userErrors { field message } } }`;
+const M_QTY = `mutation Q($input: InventorySetQuantitiesInput!) {
+  inventorySetQuantities(input: $input) { inventoryAdjustmentGroup { createdAt } userErrors { field message } } }`;
+const M_POLICY = `mutation P($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
+  productVariantsBulkUpdate(productId: $productId, variants: $variants) {
+    productVariants { id inventoryPolicy } userErrors { field message } } }`;
+
+function qty(level, name) {
+  return (level.quantities || []).find((q) => q.name === name)?.quantity ?? 0;
+}
+
+// ---------------------------------------------------------------- build plan
+let plan;
+if (a.rollback) {
+  const rows = readJsonl(LEDGER).filter((r) => r.applied);
+  const latest = new Map();
+  for (const r of rows) if (!latest.has(r.variantId)) latest.set(r.variantId, r); // FIRST write = true preimage
+  plan = [...latest.values()].map((r) => ({
+    productId: r.productId, handle: r.handle, variantId: r.variantId, sku: r.sku, title: r.title,
+    inventoryItemId: r.inventoryItemId,
+    targetPolicy: r.before.inventoryPolicy,
+    targetTracked: r.before.tracked,
+    targetLevels: r.before.levels,
+    before: null,
+  }));
+} else {
+  plan = [];
+  for (const p of loadTargets()) {
+    for (const v of p.variants) {
+      if (v.isSample ?? isSampleVariant(v)) continue;           // never touch the swatch
+      plan.push({
+        productId: p.id, handle: p.handle, variantId: v.id, sku: v.sku, title: v.title, price: v.price,
+        inventoryItemId: v.inventoryItem.id,
+        targetPolicy: 'DENY', targetTracked: true,
+        targetLevels: (v.inventoryItem.inventoryLevels || []).map((l) => ({ locationId: l.location.id, on_hand: 0 })),
+        before: {
+          inventoryPolicy: v.inventoryPolicy,
+          tracked: v.inventoryItem.tracked,
+          levels: (v.inventoryItem.inventoryLevels || []).map((l) => ({
+            locationId: l.location.id, on_hand: qty(l, 'on_hand'), available: qty(l, 'available'),
+          })),
+        },
+      });
+    }
+  }
+}
+
+const needTrack = plan.filter((x) => a.rollback ? x.targetTracked === false : true).length;
+const trackFlips = a.rollback
+  ? plan.filter((x) => x.targetTracked === false).length
+  : plan.filter((x) => x.before.tracked === false).length;
+const policyFlips = a.rollback
+  ? plan.filter((x) => x.targetPolicy !== 'DENY').length
+  : plan.filter((x) => x.before.inventoryPolicy !== 'DENY').length;
+const qtyOps = plan.reduce((n, x) => n + x.targetLevels.length, 0);
+const sampleSkipped = a.rollback ? 'n/a' : loadTargets().reduce((n, p) => n + p.variants.filter((v) => v.isSample ?? isSampleVariant(v)).length, 0);
+const productsTouched = new Set(plan.map((x) => x.productId)).size;
+
+console.log(`plan (${a.rollback ? 'ROLLBACK' : 'HARDEN'}):`);
+console.log(`  non-sample variants        : ${plan.length}  across ${productsTouched} products`);
+console.log(`  inventoryItemUpdate tracked: ${trackFlips} flips  -> ${a.rollback ? 'false (restore)' : 'true'}`);
+console.log(`  inventorySetQuantities     : ${qtyOps} location rows -> ${a.rollback ? 'restored on_hand' : 'on_hand 0'}`);
+console.log(`  inventoryPolicy            : ${policyFlips} flips  -> ${a.rollback ? 'restored' : 'DENY'}`);
+console.log(`  SAMPLE variants untouched  : ${sampleSkipped}`);
+for (const x of plan.slice(0, 3)) {
+  console.log(`  e.g. ${x.handle} / "${x.title}" ${x.sku || '(no sku)'} — tracked ${a.rollback ? '->' + x.targetTracked : (x.before.tracked + ' -> true')}, on_hand ${a.rollback ? JSON.stringify(x.targetLevels) : JSON.stringify(x.before.levels.map((l) => l.on_hand)) + ' -> 0'}`);
+}
+if (!a.apply) { console.log('\nDRY-RUN: nothing was written.'); process.exit(0); }
+
+// ---------------------------------------------------------------- apply
+let ok = 0, fail = 0;
+const failed = [];
+function note(msg) { fail++; failed.push(msg); console.error('  FAIL ' + msg); }
+
+// Order matters. Forward: tracked -> qty -> policy. Rollback: policy -> qty -> tracked(false last).
+const steps = a.rollback ? ['policy', 'qty', 'tracked'] : ['tracked', 'qty', 'policy'];
+
+for (const step of steps) {
+  if (step === 'tracked') {
+    const items = plan.filter((x) => a.rollback ? x.targetTracked === false : x.before.tracked !== true);
+    for (const x of items) {
+      const d = await gql(M_TRACK, { id: x.inventoryItemId, input: { tracked: a.rollback ? x.targetTracked : true } });
+      const e = d.inventoryItemUpdate?.userErrors || [];
+      e.length ? note(`tracked ${x.sku}: ${JSON.stringify(e)}`) : ok++;
+    }
+    console.log(`  tracked step: ${items.length} calls`);
+  }
+  if (step === 'qty') {
+    const rows = [];
+    for (const x of plan) {
+      for (const l of x.targetLevels) {
+        rows.push({ inventoryItemId: x.inventoryItemId, locationId: l.locationId, quantity: a.rollback ? l.on_hand : 0 });
+      }
+    }
+    for (const b of chunk(rows, 200)) {
+      const d = await gql(M_QTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: b } });
+      const e = d.inventorySetQuantities?.userErrors || [];
+      e.length ? note(`qty batch(${b.length}): ${JSON.stringify(e).slice(0, 300)}`) : (ok += b.length);
+    }
+    console.log(`  qty step: ${rows.length} rows in ${Math.ceil(rows.length / 200)} batches`);
+  }
+  if (step === 'policy') {
+    const byProduct = new Map();
+    for (const x of plan) {
+      if (!byProduct.has(x.productId)) byProduct.set(x.productId, []);
+      byProduct.get(x.productId).push({ id: x.variantId, inventoryPolicy: a.rollback ? x.targetPolicy : 'DENY' });
+    }
+    for (const [pid, variants] of byProduct) {
+      const d = await gql(M_POLICY, { productId: pid, variants });
+      const e = d.productVariantsBulkUpdate?.userErrors || [];
+      e.length ? note(`policy ${pid}: ${JSON.stringify(e).slice(0, 200)}`) : (ok += variants.length);
+    }
+    console.log(`  policy step: ${byProduct.size} productVariantsBulkUpdate calls`);
+  }
+}
+
+if (!a.rollback) {
+  for (const x of plan) {
+    appendJsonl(LEDGER, {
+      ts: new Date().toISOString(), productId: x.productId, handle: x.handle, variantId: x.variantId,
+      sku: x.sku, title: x.title, inventoryItemId: x.inventoryItemId, before: x.before,
+      after: { inventoryPolicy: 'DENY', tracked: true, levels: x.targetLevels }, applied: true,
+    });
+  }
+}
+
+console.log(`\ndone: ${ok} successful ops, ${fail} failures`);
+if (ok && !a.rollback) logReversible({
+  action: `TK-11925 harden ${plan.length} non-sample variants (tracked:true + on_hand 0 + policy DENY) across ${productsTouched} products`,
+  blast: plan.length,
+  undo: 'cd ~/Projects/dw-contact-us-pages && node scripts/harden-variants.mjs --rollback --apply',
+  verify: 'cd ~/Projects/dw-contact-us-pages && node scripts/verify.mjs',
+});
+process.exit(fail ? 1 : 0);
diff --git a/scripts/unpublish-channels.mjs b/scripts/unpublish-channels.mjs
new file mode 100644
index 0000000..24c636f
--- /dev/null
+++ b/scripts/unpublish-channels.mjs
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+// unpublish-channels.mjs — remove the cohort from 3 sales channels ONLY. TK-11925.
+//   node scripts/unpublish-channels.mjs           # dry-run
+//   node scripts/unpublish-channels.mjs --apply
+//   node scripts/unpublish-channels.mjs --rollback --apply
+//
+// Channels (Steve-specified, nothing else):
+//   Google & YouTube  gid://shopify/Publication/29646651457
+//   Shop              gid://shopify/Publication/44317507635
+//   Buy Button        gid://shopify/Publication/22497296496
+// Online Store stays PUBLISHED — the PDP must remain reachable; it is the contact-us page.
+import { join } from 'node:path';
+import { ROOT, parseArgs, banner, gql, loadTargets, appendJsonl, readJsonl, logReversible, TARGET_PUBLICATIONS } from './lib.mjs';
+
+const a = parseArgs();
+const LEDGER = join(ROOT, 'data', 'ledger-channels.jsonl');
+banner(a.rollback ? 'unpublish-channels --rollback' : 'unpublish-channels', a.apply);
+
+const M_UNPUB = `mutation U($id: ID!, $input: [PublicationInput!]!) {
+  publishableUnpublish(id: $id, input: $input) { publishable { availablePublicationsCount { count } } userErrors { field message } } }`;
+const M_PUB = `mutation P($id: ID!, $input: [PublicationInput!]!) {
+  publishablePublish(id: $id, input: $input) { publishable { availablePublicationsCount { count } } userErrors { field message } } }`;
+
+const byId = new Map(TARGET_PUBLICATIONS.map((p) => [p.id, p.name]));
+let work;
+if (a.rollback) {
+  const rows = readJsonl(LEDGER).filter((r) => r.applied);
+  const m = new Map();
+  for (const r of rows) if (!m.has(r.productId)) m.set(r.productId, r); // first record = true preimage
+  work = [...m.values()]
+    .map((r) => ({ id: r.productId, handle: r.handle, pubs: r.before.filter((b) => b.isPublished).map((b) => b.publicationId) }))
+    .filter((w) => w.pubs.length);
+} else {
+  work = [];
+  for (const p of loadTargets()) {
+    const pubs = p.resourcePublicationsV2.filter((rp) => byId.has(rp.publication.id) && rp.isPublished);
+    if (!pubs.length) continue;
+    work.push({
+      id: p.id, handle: p.handle, pubs: pubs.map((rp) => rp.publication.id),
+      before: p.resourcePublicationsV2.filter((rp) => byId.has(rp.publication.id))
+        .map((rp) => ({ publicationId: rp.publication.id, name: rp.publication.name, isPublished: rp.isPublished })),
+    });
+  }
+}
+
+const ops = work.reduce((n, w) => n + w.pubs.length, 0);
+const perChannel = {};
+for (const w of work) for (const id of w.pubs) perChannel[byId.get(id) || id] = (perChannel[byId.get(id) || id] || 0) + 1;
+console.log(`plan (${a.rollback ? 'RE-PUBLISH' : 'UNPUBLISH'}): ${work.length} products, ${ops} publication operations`);
+console.log('  per channel: ' + JSON.stringify(perChannel));
+console.log('  Online Store is NOT in scope and stays published.');
+if (!a.apply) { console.log('\nDRY-RUN: nothing was written.'); process.exit(0); }
+
+let ok = 0, fail = 0;
+for (const w of work) {
+  const input = w.pubs.map((publicationId) => ({ publicationId }));
+  const d = await gql(a.rollback ? M_PUB : M_UNPUB, { id: w.id, input });
+  const node = a.rollback ? d.publishablePublish : d.publishableUnpublish;
+  const e = node?.userErrors || [];
+  if (e.length) { fail++; console.error(`  FAIL ${w.handle}: ${JSON.stringify(e).slice(0, 200)}`); }
+  else {
+    ok++;
+    if (!a.rollback) appendJsonl(LEDGER, { ts: new Date().toISOString(), productId: w.id, handle: w.handle, before: w.before, unpublished: w.pubs, applied: true });
+    if (ok % 50 === 0) process.stderr.write(`\r  ${ok}/${work.length}   `);
+  }
+}
+console.log(`\ndone: ${ok} products ok, ${fail} failed`);
+if (ok && !a.rollback) logReversible({
+  action: `TK-11925 unpublish ${ok} products from Google & YouTube / Shop / Buy Button (${ops} publication ops)`,
+  blast: ops,
+  undo: 'cd ~/Projects/dw-contact-us-pages && node scripts/unpublish-channels.mjs --rollback --apply',
+  verify: 'cd ~/Projects/dw-contact-us-pages && node scripts/verify.mjs',
+});
+process.exit(fail ? 1 : 0);

← f5fc64f scripts: push/rollback theme with preimage capture; refuses  ·  back to Dw Contact Us Pages  ·  verify.mjs (PASS/FAIL/NOT-MEASURED) + README with order of o b61f5af →