[object Object]

← back to Designer Wallcoverings

Kravet 3b-activate: validating activator (validate-before-activate live gate, publish ex-Google, cap-25, reversible) — held all 173 (no live images), tagged Needs-Image

8004c8be8235a738a2da20123a76d84507d65953 · 2026-08-19 10:22:52 -0700 · Steve

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8004c8be8235a738a2da20123a76d84507d65953
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 19 10:22:52 2026 -0700

    Kravet 3b-activate: validating activator (validate-before-activate live gate, publish ex-Google, cap-25, reversible) — held all 173 (no live images), tagged Needs-Image
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .../kravet-3b-activate-validated.js                | 255 +++++++++++++++++++++
 1 file changed, 255 insertions(+)

diff --git a/shopify/scripts/data/kravet-map-pricing/kravet-3b-activate-validated.js b/shopify/scripts/data/kravet-map-pricing/kravet-3b-activate-validated.js
new file mode 100644
index 00000000..71fe0fce
--- /dev/null
+++ b/shopify/scripts/data/kravet-map-pricing/kravet-3b-activate-validated.js
@@ -0,0 +1,255 @@
+#!/usr/bin/env node
+/**
+ * kravet-3b-activate-validated.js  (TK-10688 / Kravet 3b-ACTIVATE go-live)
+ *
+ * Flip READY Kravet-family worklist products ARCHIVED->ACTIVE, SAFELY + BOUNDED.
+ * Successor to kravet25-reactivate.js — but with the HARD gates Steve requires:
+ *
+ *   GATE 1  VALIDATE-BEFORE-ACTIVATE per product against LIVE Shopify
+ *           (canonical validator shopify/scripts/lib/validate-before-activate.js).
+ *           image_url in the dw_unified mirror is unreliable -> we read live media
+ *           + metafields + variants and validate on THAT. Only PASS activates.
+ *   GATE 2  FIRST-BATCH CAP = 25 activations, then STOP.
+ *   GATE 3  Publish to every channel EXCEPT 'Google & YouTube' (never add Google).
+ *   GATE 4  Reversibility — write prior status + prior publication ids to an undo
+ *           artifact under ~/.claude/yolo-queue/executed-reversible/ BEFORE any write.
+ *   GATE 5  FAIL -> skip, leave ARCHIVED, tag Needs-Image/Needs-Width/etc.
+ *   GATE 7  never-activate lines (Nicolette Mayer/Gracie/Fromental/Zuber/De Gournay)
+ *           are hard-skipped (belt-and-suspenders; none expected in worklist).
+ *
+ *   node kravet-3b-activate-validated.js            # DRY-RUN (validate + report, no write)
+ *   node kravet-3b-activate-validated.js --apply    # LIVE (activate up to CAP)
+ *   node kravet-3b-activate-validated.js --cap=25    # override cap
+ */
+'use strict';
+const https = require('https');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { validateBeforeActivate } = require(os.homedir() + '/Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js');
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const CAP = Number((args.find(a => a.startsWith('--cap=')) || '--cap=25').split('=')[1]) || 25;
+const TARGET_QTY = 2026;
+const LOCATION_ID = 'gid://shopify/Location/5795643504';
+const TODAY = new Date().toISOString().slice(0, 10);
+const WORKLIST = os.homedir() + '/.claude/yolo-queue/pending-approval/_superseded/kravet-roll-reactivation-worklist-2026-06-17.csv';
+const UNDO_DIR = os.homedir() + '/.claude/yolo-queue/executed-reversible';
+const EPOCH = Math.floor(Date.now() / 1000);
+const UNDO_CSV = path.join(UNDO_DIR, `kravet-3b-activate-undo-${EPOCH}.csv`);
+const RESULT_JSON = path.join(UNDO_DIR, `kravet-3b-activate-result-${EPOCH}.json`);
+
+const NEVER_ACTIVATE = /nicolette\s*mayer|gracie|fromental|zuber|de\s*gournay/i;
+
+const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// Publish set EXCLUDES 'Google & YouTube' (29646651457) — GMC fed by controlled TSV only.
+const PUBS = [
+  ['Online Store', '22208643184'], ['Buy Button', '22497296496'],
+  ['Facebook & Instagram', '29739483201'], ['Houzz', '29776969793'], ['Point of Sale', '37904089153'],
+  ['Fabricut', '43657658419'], ['Pinterest', '44234276915'], ['Rakuten Ichiba (JP)', '44317474867'],
+  ['Shop', '44317507635'], ['Inbox', '71898464307'], ['TikTok', '115856375859'], ['DWAutoPostBlog', '140027723827'],
+].map(([name, id]) => ({ name, gid: `gid://shopify/Publication/${id}`, alias: 'p' + id }));
+const PUB_ALIASES = PUBS.map(p => `${p.alias}:publishedOnPublication(publicationId:"${p.gid}")`).join(' ');
+
+function gql(query, variables = {}) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({ query, variables });
+    const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) } },
+      r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(d); } }); });
+    req.on('error', reject); req.write(body); req.end();
+  });
+}
+async function gqlR(q, v = {}, tries = 5) {
+  for (let i = 0; i < tries; i++) {
+    let r; try { r = await gql(q, v); } catch (e) { r = { errors: [{ message: String(e).slice(0, 120) }] }; }
+    if (r && !r.errors) return r;
+    await sleep(1500 * (i + 1));
+  }
+  return gql(q, v);
+}
+
+// Look up a product by variant SKU (base dw_sku's roll variant), then fetch full detail.
+const Q_BY_SKU = `query($q:String!){productVariants(first:5,query:$q){nodes{id sku product{id}}}}`;
+const Q_DETAIL = `query($id:ID!){product(id:$id){
+  id title status vendor tags handle descriptionHtml ${PUB_ALIASES}
+  featuredImage{url}
+  media(first:50){nodes{... on MediaImage{image{url}}}}
+  metafields(first:60){nodes{namespace key value}}
+  variants(first:40){nodes{id title sku price inventoryItem{id}}}
+}}`;
+const M_STATUS = `mutation($input:ProductInput!){productUpdate(input:$input){product{id status} userErrors{field message}}}`;
+const M_PUBLISH = `mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{field message}}}`;
+const M_TAGS = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+const M_SET = `mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{field message}}}`;
+const M_ACTIVATE = `mutation($itemId:ID!,$locId:ID!,$qty:Int!){inventoryActivate(inventoryItemId:$itemId,locationId:$locId,available:$qty){userErrors{field message}}}`;
+
+function splitCsvLine(line) {
+  const out = []; let cur = ''; let inQ = false;
+  for (let i = 0; i < line.length; i++) {
+    const ch = line[i];
+    if (inQ) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; } else cur += ch; }
+    else if (ch === '"') inQ = true;
+    else if (ch === ',') { out.push(cur); cur = ''; }
+    else cur += ch;
+  }
+  out.push(cur); return out;
+}
+function parseCsv(p) {
+  const [head, ...lines] = fs.readFileSync(p, 'utf8').trim().split('\n');
+  const cols = splitCsvLine(head);
+  return lines.filter(Boolean).map(l => { const v = splitCsvLine(l); const o = {}; cols.forEach((c, i) => o[c] = v[i]); return o; });
+}
+
+function mf(metafields, ns, key) {
+  const m = (metafields || []).find(x => x.namespace === ns && x.key === key);
+  return m ? m.value : '';
+}
+
+async function setInvAll(variants) {
+  const qs = variants.filter(v => v.inventoryItem?.id)
+    .map(v => ({ inventoryItemId: v.inventoryItem.id, locationId: LOCATION_ID, quantity: TARGET_QTY }));
+  if (!qs.length) return { ok: false, err: 1 };
+  let r = await gqlR(M_SET, { input: { reason: 'correction', name: 'available', ignoreCompareQuantity: true, quantities: qs } });
+  if (!(r.data?.inventorySetQuantities?.userErrors || []).length) return { ok: true, err: 0 };
+  let err = 0;
+  for (const q of qs) {
+    const sr = await gqlR(M_SET, { input: { reason: 'correction', name: 'available', ignoreCompareQuantity: true, quantities: [q] } });
+    if (!(sr.data?.inventorySetQuantities?.userErrors || []).length) continue;
+    const ar = await gqlR(M_ACTIVATE, { itemId: q.inventoryItemId, locId: LOCATION_ID, qty: TARGET_QTY });
+    if ((ar.data?.inventoryActivate?.userErrors || []).length) err++;
+    await sleep(60);
+  }
+  return { ok: err === 0, err };
+}
+
+(async () => {
+  const rows = parseCsv(WORKLIST);
+  console.log(`kravet-3b-activate-validated — ${rows.length} worklist rows — CAP=${CAP} — ${APPLY ? '🔴 LIVE' : 'DRY-RUN'}\n`);
+
+  const undoRecords = [];   // {shopify_id, dw_sku, prior_status, prior_published_pub_ids}
+  const activated = [];     // dw_skus
+  const failedVal = [];     // {dw_sku, reasons}
+  const notFound = [];      // dw_sku
+  const skippedNever = [];  // dw_sku
+  let errored = 0;
+
+  for (const row of rows) {
+    if (activated.length >= CAP) break;
+
+    const baseSku = String(row.dw_sku || '').replace(/-Sample$/i, '');
+    const vendor = row.vendor || '';
+
+    if (NEVER_ACTIVATE.test(vendor) || NEVER_ACTIVATE.test(row.title || '')) {
+      skippedNever.push(baseSku); continue;
+    }
+
+    // find the product via any variant SKU that starts with the base dw_sku
+    const vr = await gqlR(Q_BY_SKU, { q: `sku:${baseSku}*` });
+    const vnodes = (vr.data?.productVariants?.nodes || []).filter(n => (n.sku || '').toUpperCase().startsWith(baseSku.toUpperCase()));
+    const prodId = vnodes[0]?.product?.id;
+    if (!prodId) { notFound.push(baseSku); continue; }
+
+    const pr = await gqlR(Q_DETAIL, { id: prodId });
+    const p = pr.data?.product;
+    if (!p) { notFound.push(baseSku); continue; }
+
+    // Belt+suspenders on live vendor too
+    if (NEVER_ACTIVATE.test(p.vendor || '') || NEVER_ACTIVATE.test(p.title || '')) { skippedNever.push(baseSku); continue; }
+
+    const variants = p.variants.nodes;
+    const sample = variants.find(v => /sample/i.test(v.title || '') || /sample/i.test(v.sku || ''));
+    const roll = variants.find(v => v !== sample) || null;
+    const mediaImgs = (p.media?.nodes || []).map(n => n.image?.url).filter(Boolean);
+    const images = p.featuredImage?.url ? [p.featuredImage.url, ...mediaImgs] : mediaImgs;
+
+    const specs = {
+      width: mf(p.metafields.nodes, 'global', 'width') || mf(p.metafields.nodes, 'custom', 'width'),
+      length: mf(p.metafields.nodes, 'global', 'length'),
+      repeat: mf(p.metafields.nodes, 'global', 'repeat'),
+      material: mf(p.metafields.nodes, 'global', 'Content') || mf(p.metafields.nodes, 'global', 'Contents'),
+      unitOfMeasure: mf(p.metafields.nodes, 'global', 'unit_of_measure'),
+    };
+    // vendorSpecs: we don't have the vendor row here; only enforce width hard-req (validator does that).
+    const vendorSpecs = {};
+
+    const gate = validateBeforeActivate({
+      title: p.title, vendor: p.vendor, tags: p.tags, dwSku: baseSku,
+      descriptionHtml: p.descriptionHtml, specs, vendorSpecs,
+      images, vendorImages: [], variants,
+    });
+
+    // Extra sellable-roll-at-MAP check (Steve's spec): a real roll variant priced > $5.
+    const rollPrice = parseFloat(roll?.price || '0');
+    const sellableRollOK = roll && rollPrice > 5;
+
+    const tag = `[${p.status}] $${rollPrice.toFixed(2)}`;
+    if (!gate.ok || !sellableRollOK) {
+      const reasons = [...gate.reasons];
+      if (!sellableRollOK) reasons.push(roll ? `roll price $${rollPrice.toFixed(2)} not > $5` : 'no sellable roll variant');
+      failedVal.push({ dw_sku: baseSku, reasons });
+      console.log(`✗ ${baseSku.padEnd(14)} ${tag}  FAIL: ${reasons.join('; ')}`);
+      if (APPLY && gate.tags.length) {
+        await gqlR(M_TAGS, { id: prodId, tags: gate.tags });
+      }
+      continue;
+    }
+
+    // PASS. Record undo BEFORE any write.
+    const priorPubs = PUBS.filter(pub => p[pub.alias]).map(pub => pub.gid);
+    const numId = prodId.split('/').pop();
+    undoRecords.push({ shopify_id: numId, dw_sku: baseSku, prior_status: p.status, prior_pub_ids: priorPubs.join('|') });
+
+    console.log(`${APPLY ? '✎' : '·'} ${baseSku.padEnd(14)} ${tag}  PASS -> ACTIVE + publish(${PUBS.length}ch, no-Google) + inv=${TARGET_QTY}  | ${p.title.slice(0, 40)}`);
+
+    if (!APPLY) { activated.push(baseSku); continue; }
+
+    // 1. status ACTIVE
+    const sr = await gqlR(M_STATUS, { input: { id: prodId, status: 'ACTIVE' } });
+    if ((sr.data?.productUpdate?.userErrors || []).length) {
+      console.log(`   ❌ status ${JSON.stringify(sr.data.productUpdate.userErrors)}`); errored++;
+      undoRecords.pop(); continue;
+    }
+    // 2. publish missing channels (Google intentionally absent from PUBS)
+    const missing = PUBS.filter(pub => !p[pub.alias]).map(pub => ({ publicationId: pub.gid }));
+    if (missing.length) {
+      const ppr = await gqlR(M_PUBLISH, { id: prodId, input: missing });
+      if ((ppr.data?.publishablePublish?.userErrors || []).length)
+        console.log(`   ⚠ publish ${JSON.stringify(ppr.data.publishablePublish.userErrors)}`);
+    }
+    // 3. inventory = 2026 every variant
+    const inv = await setInvAll(variants);
+    if (!inv.ok) console.log(`   ⚠ inv errors ${inv.err}`);
+    activated.push(baseSku);
+    await sleep(150);
+  }
+
+  // Write undo artifact + result JSON
+  if (APPLY && undoRecords.length) {
+    const csv = ['shopify_id,dw_sku,prior_status,prior_pub_ids', ...undoRecords.map(u => `${u.shopify_id},${u.dw_sku},${u.prior_status},"${u.prior_pub_ids}"`)].join('\n') + '\n';
+    fs.writeFileSync(UNDO_CSV, csv);
+  }
+  const result = {
+    epoch: EPOCH, apply: APPLY, cap: CAP,
+    activated_count: activated.length, activated,
+    failed_validation_count: failedVal.length, failed_validation: failedVal,
+    not_found_count: notFound.length, not_found: notFound,
+    skipped_never_activate: skippedNever,
+    errored,
+    undo_csv: APPLY && undoRecords.length ? UNDO_CSV : null,
+  };
+  if (APPLY) fs.writeFileSync(RESULT_JSON, JSON.stringify(result, null, 2));
+
+  console.log(`\n=== SUMMARY (${APPLY ? 'LIVE' : 'DRY-RUN'}) ===`);
+  console.log(`activated:        ${activated.length}${activated.length >= CAP ? '  (CAP reached — STOP)' : ''}`);
+  console.log(`failed validation:${failedVal.length}`);
+  console.log(`not found live:   ${notFound.length}`);
+  console.log(`skipped never-act:${skippedNever.length}`);
+  console.log(`errored:          ${errored}`);
+  if (APPLY) { console.log(`undo csv:  ${result.undo_csv}`); console.log(`result:    ${RESULT_JSON}`); }
+})();

← 8673d664 auto-data-snapshot: 2026-08-19T06:24:18 (1 data files) — sho  ·  back to Designer Wallcoverings  ·  chore: v1.2.15 (session close — Kravet roll-add hardening + d8123a7a →