← back to Reid Witlin Onboarding

backfill-rwltd-mfr.mjs.bak

129 lines

#!/usr/bin/env node
/**
 * TK-10066 follow-on — Reid Witlin (private-labeled "Architectural Fabrics")
 * go-live-gate mfr# backfill.
 *
 * Discovered 2026-09-03 while verifying an unrelated fix (TK-11177): after a
 * fleet-wide metafields re-sync fixed ~3 months of stale mirror data, the
 * dw-golive-gate-canary live-verified 500 recently-created "Architectural
 * Fabrics" products with NO custom.manufacturer_sku metafield at all — a real
 * gap, not mirror lag. The batch-creation scripts under TK-10066 minted DW
 * SKUs (DWKR-xxxxxx) and set shopify_product_id but never wrote the mfr#
 * metafield.
 *
 * Source of truth for the value: rwltd_catalog.mfr_sku (the real Reid Witlin
 * pattern/colorway slug, e.g. "no-chill-white"), exported to
 * rwltd-mfr-mapping.csv (1,195 rows — every catalog row with both a
 * shopify_product_id and a source mfr_sku). Target metafield namespace/key
 * matches every other vendor on the gate (custom.manufacturer_sku,
 * single_line_text_field) — same pattern as the PJ backfill this was found
 * alongside (~/Projects/designerwallcoverings/scripts/pj-mfr-backfill/).
 *
 * SAFETY / RAILS:
 *   - DRY-RUN by default. Pass --apply to write to Shopify (customer-facing =
 *     Steve-GATED; do NOT --apply without an approved memo).
 *   - Idempotent: GETs each product's metafields first and SKIPs any that
 *     already carry a non-empty manufacturer_sku.
 *   - Records a rollback ledger (product_id -> had_before) so every write is
 *     reversible.
 *   - Batches of 25 with a >=90s gap between batches (DW bulk-push rule).
 *   - Hard-fails on GraphQL top-level errors or userErrors (never a silent
 *     no-op).
 *
 * COST: $0 (no AI). Pure Admin API GET + metafieldsSet.
 */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dir = path.dirname(fileURLToPath(import.meta.url));
const STORE = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const APPLY = process.argv.includes('--apply');
const BATCH = 25;
const GAP_MS = 90_000;
const CSV = path.join(__dir, 'rwltd-mfr-mapping.csv');
const LEDGER = path.join(__dir, 'rwltd-mfr-rollback-ledger.jsonl');

const TOKEN = (() => {
  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
  const m = env.split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
  if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN not found');
  return m.split('=').slice(1).join('=').replace(/["'\r ]/g, '');
})();

const sleep = ms => new Promise(r => setTimeout(r, ms));

function parseCsv(txt) {
  const [head, ...rows] = txt.trim().split('\n');
  const cols = head.split(',');
  return rows.map(line => {
    const parts = line.split(',');
    const rec = {};
    cols.forEach((c, i) => (rec[c] = parts[i]));
    return rec;
  });
}

async function gql(query, variables) {
  const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
    method: 'POST',
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  const j = await res.json();
  if (j.errors) throw new Error('GraphQL errors: ' + JSON.stringify(j.errors));
  return j.data;
}

async function liveHasMfr(numericId) {
  const res = await fetch(`https://${STORE}/admin/api/${API}/products/${numericId}/metafields.json`, {
    headers: { 'X-Shopify-Access-Token': TOKEN },
  });
  const d = await res.json();
  return (d.metafields || []).some(m => m.key && m.key.toLowerCase() === 'manufacturer_sku' && (m.value || '').trim());
}

const SET = `mutation($mf:[MetafieldsSetInput!]!){
  metafieldsSet(metafields:$mf){
    metafields{ id namespace key value }
    userErrors{ field message }
  }
}`;

async function main() {
  const recs = parseCsv(fs.readFileSync(CSV, 'utf8'));
  const bad = recs.filter(r => !r.product_numeric_id || !r.new_manufacturer_sku);
  if (bad.length) throw new Error(`${bad.length} rows missing product id or mfr# — aborting`);
  console.log(`[${APPLY ? 'APPLY' : 'DRY-RUN'}] map=${path.basename(CSV)} — ${recs.length} Reid Witlin products to backfill custom.manufacturer_sku`);

  let wrote = 0, skipped = 0, i = 0;
  for (let b = 0; b < recs.length; b += BATCH) {
    const batch = recs.slice(b, b + BATCH);
    for (const r of batch) {
      i++;
      const pid = r.product_numeric_id;
      const val = String(r.new_manufacturer_sku).trim();
      const already = await liveHasMfr(pid);
      if (already) { skipped++; continue; }
      if (!APPLY) {
        if (i <= 5 || i % 100 === 0) console.log(`  DRY ${r.dw_sku} (${pid}) -> custom.manufacturer_sku='${val}'`);
        wrote++;
        continue;
      }
      const data = await gql(SET, {
        mf: [{ ownerId: `gid://shopify/Product/${pid}`, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: val }],
      });
      const ue = data.metafieldsSet.userErrors;
      if (ue.length) throw new Error(`userErrors on ${pid}: ${JSON.stringify(ue)}`);
      fs.appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), product_id: pid, dw_sku: r.dw_sku, namespace: 'custom', key: 'manufacturer_sku', new_value: val, had_before: false }) + '\n');
      wrote++;
    }
    console.log(`  batch ${b / BATCH + 1}: cumulative wrote=${wrote} skipped=${skipped}`);
    if (APPLY && b + BATCH < recs.length) await sleep(GAP_MS);
  }
  console.log(`DONE. ${APPLY ? 'wrote' : 'would-write'}=${wrote} skipped(already-had)=${skipped} of ${recs.length}`);
  if (APPLY) console.log('Next: re-run sync-shopify-metafields.js so the canary sees the metafield, then re-run dw-golive-gate-canary.');
}
main().catch(e => { console.error('FATAL', e); process.exit(1); });