← back to Imageless Rescue

scripts/audit-active-gate.js

161 lines

#!/usr/bin/env node
'use strict';
/**
 * READ-ONLY live audit: scan the LIVE ACTIVE catalog for activation-gate
 * violations — ACTIVE products missing SPECS (no width metafield) OR a real
 * DESCRIPTION OR an IMAGE. Groups by vendor × violation type with counts.
 *
 * Uses a Shopify bulkOperation (async, not rate-limited) to export every ACTIVE
 * product with the gate fields, then evaluates each against the same shared
 * validator the activation chokepoints use (no all_images cross-check available
 * here — that's a vendor-row join — so IMAGE check is "has >=1 image").
 *
 * Output: data/active-gate-violations-<date>.json + a printed table.
 * NO writes to Shopify or dw_unified.
 */
const https = require('https');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { validateBeforeActivate } = require(path.join(os.homedir(),
  'Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js'));

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 TODAY = new Date().toISOString().slice(0, 10);
const OUT = path.join(__dirname, '..', 'data', `active-gate-violations-${TODAY}.json`);
const sleep = ms => new Promise(r => setTimeout(r, ms));

function gql(query, variables) {
  return new Promise(res => {
    const data = JSON.stringify({ query, variables: variables || {} });
    const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
      r => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{ try{res(JSON.parse(d))}catch{res({_raw:d.slice(0,300)})} }); });
    req.on('error', e => res({ _err: e.message }));
    req.write(data); req.end();
  });
}
// Stream the (potentially multi-GB) bulk JSONL to disk — never buffer in a JS
// string (>512MB blows the string length cap).
function downloadTo(u, dest) {
  return new Promise((res, rej) => {
    const f = fs.createWriteStream(dest);
    https.get(u, r => { r.pipe(f); f.on('finish', () => f.close(() => res(dest))); }).on('error', rej);
  });
}

// Bulk query: every ACTIVE product + its images, variants, and the spec metafields.
const BULK = `mutation {
  bulkOperationRunQuery(query: """
    {
      products(query: "status:active") {
        edges { node {
          id title vendor descriptionHtml
          images(first: 5) { edges { node { url } } }
          variants(first: 5) { edges { node { sku price } } }
          metafields(first: 30) { edges { node { namespace key value } } }
        } }
      }
    }
  """) { bulkOperation { id status } userErrors { field message } }
}`;

(async () => {
  if (!TOKEN) { console.error('no token'); process.exit(1); }
  const start = await gql(BULK);
  const ue = start.data?.bulkOperationRunQuery?.userErrors;
  if (ue && ue.length) { console.error('bulk start error', JSON.stringify(ue)); process.exit(1); }
  console.log('bulk op started, polling…');
  let url = null;
  for (let i = 0; i < 240; i++) {
    await sleep(5000);
    const p = await gql(`{ currentBulkOperation { id status errorCode objectCount url } }`);
    const op = p.data?.currentBulkOperation;
    if (!op) continue;
    process.stdout.write(`\r  ${op.status} objects=${op.objectCount || 0}   `);
    if (op.status === 'COMPLETED') { url = op.url; break; }
    if (['FAILED','CANCELED'].includes(op.status)) { console.error('\nbulk', op.status, op.errorCode); process.exit(1); }
  }
  console.log('');
  if (!url) { console.error('bulk did not complete in time'); process.exit(1); }

  const readline = require('readline');
  const jsonlPath = path.join(__dirname, '..', 'data', `active-bulk-${TODAY}.jsonl`);
  console.log('downloading bulk JSONL…');
  await downloadTo(url, jsonlPath);
  console.log(`  saved ${(fs.statSync(jsonlPath).size/1e6).toFixed(0)} MB; parsing…`);

  // The bulk export is JSONL; top-level Product rows have id gid://shopify/Product
  // and child connection rows carry __parentId. Reassemble per-product, streamed.
  const products = new Map();
  await new Promise((resolve) => {
    const rl = readline.createInterface({ input: fs.createReadStream(jsonlPath), crlfDelay: Infinity });
    rl.on('line', (line) => {
      if (!line.trim()) return;
      let o; try { o = JSON.parse(line); } catch { return; }
      const id = o.id || '';
      if (id.includes('/Product/') && o.title !== undefined) {
        products.set(id, { id, title:o.title, vendor:o.vendor||'(none)', descriptionHtml:o.descriptionHtml||'', images:[], variants:[], metafields:[] });
      } else if (o.__parentId && products.has(o.__parentId)) {
        const p = products.get(o.__parentId);
        if (o.url) p.images.push(o.url);
        else if (o.sku !== undefined || o.price !== undefined) p.variants.push({ sku:o.sku, price:o.price });
        else if (o.namespace !== undefined) p.metafields.push({ namespace:o.namespace, key:o.key, value:o.value });
      }
    });
    rl.on('close', resolve);
  });

  const mfVal = (mfs, ns, key) => { const m = mfs.find(x => x.namespace===ns && x.key===key); return m ? m.value : ''; };
  const violations = [];
  let activeTotal = 0;
  for (const p of products.values()) {
    activeTotal++;
    const mfs = p.metafields;
    const g = validateBeforeActivate({
      title: p.title, dwSku: '',
      descriptionHtml: p.descriptionHtml,
      specs: {
        width:  mfVal(mfs,'global','width') || mfVal(mfs,'custom','width'),
        length: mfVal(mfs,'global','length'),
        repeat: mfVal(mfs,'global','repeat'),
        material: mfVal(mfs,'global','material') || mfVal(mfs,'custom','material'),
        unitOfMeasure: mfVal(mfs,'global','unit_of_measure'),
      },
      vendorSpecs: {}, // no vendor-row join in this audit; we only flag width-missing for specs
      images: p.images,
      vendorImages: p.images, // no cross-check; image gate = has >=1
      variants: p.variants.length ? p.variants : [{ sku: 'x-Sample' }], // ignore sample-variant gate in audit (no DW SKU here)
    });
    // classify ONLY the three gate dimensions of interest (specs/desc/image)
    const types = [];
    if (g.tags.includes('Needs-Specs') || g.tags.includes('Needs-Width')) types.push('specs');
    if (g.tags.includes('Needs-Description')) types.push('description');
    if (g.tags.includes('Needs-Image')) types.push('image');
    if (types.length) violations.push({ id:p.id, title:p.title, vendor:p.vendor, types, reasons:g.reasons.filter(r=>!/sample variant/i.test(r)), images:p.images.length });
  }

  // group by vendor × type
  const byVendorType = {};
  const byType = { specs:0, description:0, image:0 };
  for (const v of violations) {
    byVendorType[v.vendor] = byVendorType[v.vendor] || { specs:0, description:0, image:0, total:0 };
    for (const t of v.types) { byVendorType[v.vendor][t]++; byType[t]++; }
    byVendorType[v.vendor].total++;
  }
  const result = { date: TODAY, activeTotal, violationProducts: violations.length, byType, byVendorType, violations };
  fs.writeFileSync(OUT, JSON.stringify(result, null, 2));

  console.log(`\nACTIVE scanned: ${activeTotal}`);
  console.log(`ACTIVE with >=1 gate violation: ${violations.length}`);
  console.log(`  specs(width)-missing: ${byType.specs} · description-missing/placeholder: ${byType.description} · image-missing: ${byType.image}`);
  console.log('\nVendor × violation (top 25 by total):');
  console.log('vendor'.padEnd(34), 'specs'.padStart(6), 'desc'.padStart(6), 'img'.padStart(6), 'total'.padStart(7));
  Object.entries(byVendorType).sort((a,b)=>b[1].total-a[1].total).slice(0,25).forEach(([v,c]) => {
    console.log(v.slice(0,33).padEnd(34), String(c.specs).padStart(6), String(c.description).padStart(6), String(c.image).padStart(6), String(c.total).padStart(7));
  });
  console.log(`\nwrote ${OUT}`);
})();