← back to Dw Validator Debug TK11314
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
A shopify/scripts/data/kravet-map-pricing/kravet-3b-activate-validated.js
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 Dw Validator Debug TK11314
·
chore: v1.2.15 (session close — Kravet roll-add hardening + d8123a7a →