[object Object]

← back to Designer Wallcoverings

activated-viewer: essentials audit + width/tag cross-check + remediation worklist generator

c1e4c9dbeb428f11960f03876ac0a689b5745e84 · 2026-06-20 10:36:00 -0700 · Steve

Files touched

Diff

commit c1e4c9dbeb428f11960f03876ac0a689b5745e84
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jun 20 10:36:00 2026 -0700

    activated-viewer: essentials audit + width/tag cross-check + remediation worklist generator
---
 shopify/activated-viewer/.gitignore |  3 ++
 shopify/activated-viewer/audit.js   | 86 +++++++++++++++++++------------------
 2 files changed, 47 insertions(+), 42 deletions(-)

diff --git a/shopify/activated-viewer/.gitignore b/shopify/activated-viewer/.gitignore
index 2e8157a9..43bdbf29 100644
--- a/shopify/activated-viewer/.gitignore
+++ b/shopify/activated-viewer/.gitignore
@@ -1,3 +1,6 @@
 node_modules/
 .env
 *.log
+worklist.json
+worklist.csv
+audit-result.json
diff --git a/shopify/activated-viewer/audit.js b/shopify/activated-viewer/audit.js
index 688026cf..40211519 100644
--- a/shopify/activated-viewer/audit.js
+++ b/shopify/activated-viewer/audit.js
@@ -1,65 +1,67 @@
 'use strict';
-// Audit recently-ACTIVATED products against the 3 essentials: specs-in-metafields,
-// image (factory), priced (has a real roll/bolt, not sample-only). Read-only GraphQL.
+// Audit recently-ACTIVATED products vs the 3 essentials (priced / image / specs / width),
+// cross-check width+image against the catalog's own Needs-Width / Needs-Image tags, and
+// emit a remediation worklist grouped by gap type. Read-only GraphQL.
 const fs = require('fs'), path = require('path');
 for (const p of [path.join(__dirname, '..', '.env'), path.join(__dirname, '.env')])
   if (fs.existsSync(p)) for (const l of fs.readFileSync(p, 'utf8').split('\n')) { const m = l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); }
 const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
 const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN, VER = '2024-10';
-const MAX_PAGES = Number(process.env.MAX_PAGES || 5); // 5×250 = up to 1250
+const MAX_PAGES = Number(process.env.MAX_PAGES || 5);
 
 const Q = `query($cur:String){ products(first:250, query:"status:active", sortKey:CREATED_AT, reverse:true, after:$cur){
   pageInfo{ hasNextPage endCursor }
-  nodes{ id title handle tags featuredImage{ url } mediaCount{ count }
-    variants(first:15){ nodes{ price sku title } }
+  nodes{ id title handle tags publishedAt createdAt featuredImage{ url } mediaCount{ count }
+    variants(first:15){ nodes{ price sku } }
     metafields(first:60){ nodes{ namespace key value } } } } }`;
 
 async function gql(cur) {
   const r = await fetch(`https://${STORE}/admin/api/${VER}/graphql.json`, {
-    method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
-    body: JSON.stringify({ query: Q, variables: { cur } })
-  });
-  const j = await r.json();
-  if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 200));
+    method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: Q, variables: { cur } }) });
+  const j = await r.json(); if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 200));
   return j.data.products;
 }
 
 (async () => {
   let cur = null, all = [];
-  for (let p = 0; p < MAX_PAGES; p++) {
-    const pg = await gql(cur); all.push(...pg.nodes);
-    if (!pg.pageInfo.hasNextPage) break; cur = pg.pageInfo.endCursor;
-    await new Promise(r => setTimeout(r, 350)); // gentle on the API
-  }
-  const fail = { priced: [], image: [], specs: [], width: [] };
+  for (let p = 0; p < MAX_PAGES; p++) { const pg = await gql(cur); all.push(...pg.nodes); if (!pg.pageInfo.hasNextPage) break; cur = pg.pageInfo.endCursor; await new Promise(r => setTimeout(r, 320)); }
+
+  const rows = [], x = { priced: 0, image: 0, specs: 0, width: 0, needsWidthTag: 0, needsImageTag: 0, widthMetaMissing: 0, imageMissing: 0, widthAgree: 0, widthOnlyTag: 0, widthOnlyMeta: 0 };
   for (const p of all) {
     const sku = (p.variants.nodes[0] && p.variants.nodes[0].sku) || p.handle;
+    const tags = (p.tags || []).map(t => t.toLowerCase());
+    const mf = p.metafields.nodes, keys = mf.map(m => `${m.namespace}.${m.key}`);
     const priced = p.variants.nodes.some(v => parseFloat(v.price) > 4.25);
-    const hasImage = !!(p.featuredImage && p.featuredImage.url) && (p.mediaCount ? p.mediaCount.count > 0 : true);
-    const mfKeys = p.metafields.nodes.map(m => `${m.namespace}.${m.key}`);
-    const hasSpecs = p.metafields.nodes.some(m => m.namespace === 'specs') ||
-      mfKeys.includes('custom.ai_enriched_at') ||
-      p.metafields.nodes.filter(m => m.namespace === 'custom').length >= 5;
-    const hasWidth = mfKeys.some(k => /\.width$|\.roll_width$|\.product_width$/.test(k)) ||
-      p.metafields.nodes.some(m => m.namespace === 'custom' && /width/i.test(m.key) && m.value);
-    if (!priced) fail.priced.push(sku);
-    if (!hasImage) fail.image.push(sku);
-    if (!hasSpecs) fail.specs.push(sku);
-    if (!hasWidth) fail.width.push(sku);
+    const hasImage = !!(p.featuredImage && p.featuredImage.url) && (!p.mediaCount || p.mediaCount.count > 0);
+    const hasSpecs = mf.some(m => m.namespace === 'specs') || keys.includes('custom.ai_enriched_at') || mf.filter(m => m.namespace === 'custom').length >= 5;
+    const hasWidth = mf.some(m => /width/i.test(m.key) && m.value && String(m.value).trim() !== '');
+    const needsWidthTag = tags.includes('needs-width'), needsImageTag = tags.includes('needs-image');
+    if (priced) x.priced++; if (hasImage) x.image++; if (hasSpecs) x.specs++; if (hasWidth) x.width++;
+    if (needsWidthTag) x.needsWidthTag++; if (needsImageTag) x.needsImageTag++;
+    if (!hasWidth) x.widthMetaMissing++; if (!hasImage) x.imageMissing++;
+    // width cross-check (tag vs metafield)
+    if (needsWidthTag && !hasWidth) x.widthAgree++; else if (needsWidthTag && hasWidth) x.widthOnlyTag++; else if (!needsWidthTag && !hasWidth) x.widthOnlyMeta++;
+    const gaps = []; if (!priced) gaps.push('price'); if (!hasImage) gaps.push('image'); if (!hasSpecs) gaps.push('specs'); if (!hasWidth) gaps.push('width');
+    if (gaps.length) rows.push({ sku, handle: p.handle, id: String(p.id).split('/').pop(), title: p.title, price: p.variants.nodes.reduce((m, v) => Math.max(m, parseFloat(v.price) || 0), 0), activated: (p.publishedAt || p.createdAt || '').slice(0, 10), gaps: gaps.join('+') });
   }
-  const n = all.length, pct = x => ((n - x) / n * 100).toFixed(1);
-  const show = a => a.slice(0, 12).join(', ') + (a.length > 12 ? ` …+${a.length - 12}` : '');
-  console.log(`\n=== ACTIVATED-ITEM ESSENTIALS AUDIT — ${n} most-recent active products ===\n`);
-  console.log(`PRICED (roll/bolt > $4.25)  : ${n - fail.priced.length}/${n}  (${pct(fail.priced.length)}%)  — ${fail.priced.length} sample-only/unpriced`);
-  console.log(`HAS IMAGE                   : ${n - fail.image.length}/${n}  (${pct(fail.image.length)}%)  — ${fail.image.length} missing`);
-  console.log(`SPECS IN METAFIELDS         : ${n - fail.specs.length}/${n}  (${pct(fail.specs.length)}%)  — ${fail.specs.length} missing specs`);
-  console.log(`WIDTH METAFIELD (hard gate) : ${n - fail.width.length}/${n}  (${pct(fail.width.length)}%)  — ${fail.width.length} missing width`);
-  console.log('');
-  if (fail.priced.length) console.log(`  ✗ not priced : ${show(fail.priced)}`);
-  if (fail.image.length)  console.log(`  ✗ no image   : ${show(fail.image)}`);
-  if (fail.specs.length)  console.log(`  ✗ no specs   : ${show(fail.specs)}`);
-  if (fail.width.length)  console.log(`  ✗ no width   : ${show(fail.width)}`);
-  // persist the fail lists for follow-up
-  fs.writeFileSync(path.join(__dirname, 'audit-result.json'), JSON.stringify({ n, fail, at: new Date().toISOString() }, null, 1));
-  console.log(`\nfull fail-lists → audit-result.json`);
+  const n = all.length, pct = c => (c / n * 100).toFixed(1);
+  console.log(`\n=== ESSENTIALS AUDIT + WIDTH CROSS-CHECK — ${n} most-recent active ===\n`);
+  console.log(`priced ${pct(x.priced)}%  image ${pct(x.image)}%  specs ${pct(x.specs)}%  width ${pct(x.width)}%`);
+  console.log(`\n-- WIDTH cross-check (metafield vs Needs-Width tag) --`);
+  console.log(`  missing width metafield : ${x.widthMetaMissing}`);
+  console.log(`  carry 'Needs-Width' tag : ${x.needsWidthTag}`);
+  console.log(`  agree (tag AND no meta) : ${x.widthAgree}`);
+  console.log(`  tag set but meta present: ${x.widthOnlyTag}  (tag is stale)`);
+  console.log(`  no meta but no tag      : ${x.widthOnlyMeta}  (gap NOT flagged by tag)`);
+  console.log(`\n-- IMAGE cross-check --`);
+  console.log(`  missing image           : ${x.imageMissing}`);
+  console.log(`  carry 'Needs-Image' tag : ${x.needsImageTag}`);
+  // worklist outputs
+  const byGap = {}; rows.forEach(r => { byGap[r.gaps] = (byGap[r.gaps] || 0) + 1; });
+  fs.writeFileSync(path.join(__dirname, 'worklist.json'), JSON.stringify({ n, generatedAt: new Date().toISOString(), counts: byGap, total_gap_items: rows.length, rows }, null, 1));
+  const csv = ['sku,handle,id,gaps,price,activated,title', ...rows.map(r => `${r.sku},${r.handle},${r.id},${r.gaps},${r.price},${r.activated},"${(r.title || '').replace(/"/g, "'")}"`)].join('\n');
+  fs.writeFileSync(path.join(__dirname, 'worklist.csv'), csv);
+  console.log(`\n-- REMEDIATION WORKLIST (${rows.length} items with ≥1 gap) --`);
+  Object.entries(byGap).sort((a, b) => b[1] - a[1]).forEach(([g, c]) => console.log(`  ${String(c).padStart(4)}  ${g}`));
+  console.log(`\nwritten → worklist.json  worklist.csv`);
 })().catch(e => { console.error('AUDIT FAIL:', e.message); process.exit(1); });

← cb819a0a add inventory-set-2026-newest.js — ensure newest N products'  ·  back to Designer Wallcoverings  ·  run inventory-set-2026 sweep after each cadence run c48c81f4 →