← back to Letsbegin

rw-material-options.js

271 lines

#!/usr/bin/env node
/**
 * rw-material-options.js — write the "3 material options, priced out" metafield
 * to every Rebel Walls mural product in the 20-day cohort on the SANDBOX store.
 *
 * Steve-approved 2026-06-11: "offer all 3 options and priced out".
 *
 * For each product in:
 *   shopify_products WHERE created_at_shopify >= now()-interval '20 days'
 *                      AND vendor='Rebel Walls'   (1,000 RW murals)
 * write:
 *   - custom.material_options  (type json)  = stringified array of 3 RW material
 *       choices for the standard C3 price group, priced PER M² (USD):
 *         retail = DW sell price (RW RRP), cost = DW net.
 *   - custom.materials_available (single_line_text) = "Non-Woven; Peel & Stick; Commercial Grade"
 *
 * LEAVES AS-IS (never touched): custom.cost_per_m2 (44.5), custom.price_group (C3),
 * custom.cost_currency (USD), and every color/title/tag metafield, variant SKUs,
 * and Shopify variant prices (price computes from wall size at order).
 *
 * Guardrails:
 *   - Sandbox only (designer-laboratory-sandbox.myshopify.com).
 *   - Idempotent / resumable: a progress ledger (rw-material-options.progress.jsonl)
 *     records every DONE shopify_id; a resumed run skips them. The write itself is
 *     also naturally idempotent (metafieldsSet overwrites with the same value).
 *   - ~2 req/s with exponential backoff on throttle.
 *   - Touches ONLY custom.material_options + custom.materials_available. A
 *     concurrent SKU-backfill job writing variant SKUs / other metafields is a
 *     different field and is not disturbed.
 *
 * Usage:
 *   node rw-material-options.js --limit 3        # validate on first 3
 *   node rw-material-options.js                  # run all 1,000 (resumable)
 *   node rw-material-options.js --ids gid://...,gid://...   # specific ids
 *   node rw-material-options.js --requery N      # re-QA: print material_options
 *                                                # + cost_per_m2 for N done rows
 *
 * Env: SHOPIFY_ADMIN_TOKEN. PG* optional (defaults user stevestudio2, db dw_unified).
 */

const https = require('https');
const fs = require('fs');
const path = require('path');
const { Client } = require('pg');

const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_PRODUCT_TOKEN;
const API = '/admin/api/2024-10/graphql.json';
if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN env var required'); process.exit(1); }

// ---- guardrail: sandbox only ----
if (!/sandbox/i.test(STORE)) {
  console.error(`FATAL: refusing to run against non-sandbox store "${STORE}"`);
  process.exit(1);
}

const PROGRESS_FILE = path.join(__dirname, 'rw-material-options.progress.jsonl');

// ---- the C3-standard 3-material payload (per m², USD) ----
// retail = DW sell price (RW RRP); cost = DW net.
// Non-Woven cost is portal-confirmed; P&S + Commercial are extrapolated
// (RRP × 0.5825, the confirmed C3-standard Net/RRP ratio) pending portal Net.
const MATERIAL_OPTIONS = [
  { material: 'Non-Woven (Standard)', retail_per_m2: 76.40, cost_per_m2: 44.50, cost_confirmed: true,  source: 'partner.gimmersta.com portal Net 2026-06' },
  { material: 'Peel & Stick',         retail_per_m2: 91.70, cost_per_m2: 53.41, cost_confirmed: false, source: 'extrapolated: RRP x 0.5825 (confirmed C3-standard Net/RRP ratio); confirm via portal' },
  { material: 'Commercial Grade',     retail_per_m2: 107.00, cost_per_m2: 62.32, cost_confirmed: false, source: 'extrapolated: RRP x 0.5825; confirm via portal' },
];
const MATERIALS_AVAILABLE = 'Non-Woven; Peel & Stick; Commercial Grade';

// value must be a STRING containing valid JSON for Shopify json type.
const MATERIAL_OPTIONS_JSON = JSON.stringify(MATERIAL_OPTIONS);
// validate it parses (fail fast if the const above is ever malformed)
try {
  const parsed = JSON.parse(MATERIAL_OPTIONS_JSON);
  if (!Array.isArray(parsed) || parsed.length !== 3) throw new Error('expected 3-entry array');
} catch (e) { console.error(`FATAL: MATERIAL_OPTIONS_JSON invalid: ${e.message}`); process.exit(1); }

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

// ---------- Shopify GraphQL (gql/metafieldsSet pattern from enrich-write.js) ----------
function gqlRaw(body) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = https.request({
      hostname: STORE, path: API, method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } }); });
    req.on('error', reject);
    req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
    req.write(data); req.end();
  });
}
async function gql(body, retries = 5) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const result = await gqlRaw(body);
      const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
      const lowBudget = (result?.extensions?.cost?.throttleStatus?.currentlyAvailable || 9999) < 200;
      if (throttled) { await sleep(2500 * attempt); continue; }
      if (lowBudget) await sleep(1500);
      return result;
    } catch (e) {
      if (attempt < retries) { await sleep(attempt * 2000); continue; }
      throw e;
    }
  }
}

const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';

// ---------- progress ledger ----------
function loadDone() {
  const done = new Set();
  if (fs.existsSync(PROGRESS_FILE)) {
    for (const line of fs.readFileSync(PROGRESS_FILE, 'utf8').split('\n')) {
      if (!line.trim()) continue;
      try { const o = JSON.parse(line); if (o.status === 'done' && o.id) done.add(o.id); } catch {}
    }
  }
  return done;
}
function markDone(id) {
  fs.appendFileSync(PROGRESS_FILE, JSON.stringify({ id, status: 'done', at: new Date().toISOString() }) + '\n');
}

// ---------- cohort from PG ----------
async function loadCohort() {
  const pg = new Client({ user: process.env.PGUSER || 'stevestudio2', database: process.env.PGDATABASE || 'dw_unified', host: process.env.PGHOST || '/tmp' });
  await pg.connect();
  try {
    const res = await pg.query(
      `SELECT shopify_id FROM shopify_products
        WHERE created_at_shopify >= now() - interval '20 days'
          AND vendor = 'Rebel Walls'
          AND shopify_id IS NOT NULL
        ORDER BY shopify_id`
    );
    return res.rows.map(r => r.shopify_id);
  } finally { try { await pg.end(); } catch {} }
}

// ---------- write the 2 metafields to one product ----------
async function writeProduct(PID) {
  const mf = [
    { ownerId: PID, namespace: 'custom', key: 'material_options', value: MATERIAL_OPTIONS_JSON, type: 'json' },
    { ownerId: PID, namespace: 'custom', key: 'materials_available', value: MATERIALS_AVAILABLE, type: 'single_line_text_field' },
  ];
  const r = await gql({ query: MF_MUTATION, variables: { m: mf } });
  const errs = r?.data?.metafieldsSet?.userErrors || [];
  if (errs.length) return { ok: false, err: errs.map(e => `${(e.field || []).join('.')}: ${e.message}`).join('; ') };
  if (r?.errors?.length) return { ok: false, err: r.errors.map(e => e.message).join('; ') };
  return { ok: true };
}

// ---------- re-fetch + print material_options for verification ----------
async function fetchVerify(PID) {
  const vr = await gql({ query: `{ product(id:"${PID}") { title metafields(first:100){ edges{ node{ namespace key value type } } } } }` });
  const p = vr?.data?.product;
  if (!p) return null;
  const mfs = {};
  for (const e of (p.metafields?.edges || [])) { if (e.node.namespace === 'custom') mfs[e.node.key] = { value: e.node.value, type: e.node.type }; }
  return { title: p.title, mfs };
}

// ---------- modes ----------
async function runRequery(n) {
  const done = [...loadDone()];
  const sample = done.slice(0, n);
  if (!sample.length) { console.log('No DONE rows in progress ledger to re-QA.'); return; }
  console.log(`Re-QA on ${sample.length} done products:\n`);
  let pass = 0, fail = 0;
  for (const PID of sample) {
    const v = await fetchVerify(PID);
    await sleep(450);
    if (!v) { console.log(`  ${PID} :: NOT FOUND`); fail++; continue; }
    const mo = v.mfs['material_options'];
    const cost = v.mfs['cost_per_m2'];
    let parsed = null, parseOk = false, len = 0;
    if (mo) { try { parsed = JSON.parse(mo.value); parseOk = Array.isArray(parsed); len = parseOk ? parsed.length : 0; } catch {} }
    const moOk = !!mo && mo.type === 'json' && parseOk && len === 3;
    const costOk = !!cost && String(cost.value) === '44.5';
    if (moOk && costOk) pass++; else fail++;
    console.log(`  ${PID} | "${v.title}"`);
    console.log(`     material_options: ${moOk ? `OK (json, ${len} entries)` : 'FAIL'} | cost_per_m2=${cost ? cost.value : '(none)'} ${costOk ? 'OK (untouched)' : 'CHANGED!'}`);
  }
  console.log(`\nRe-QA result: ${pass} pass, ${fail} fail (of ${sample.length}).`);
}

async function runWrite({ limit, ids }) {
  let cohort;
  if (ids) {
    cohort = ids;
    console.log(`Mode: explicit ids (${cohort.length}).`);
  } else {
    cohort = await loadCohort();
    console.log(`Cohort from PG: ${cohort.length} RW murals (20-day window).`);
    if (limit) { cohort = cohort.slice(0, limit); console.log(`--limit ${limit} → processing first ${cohort.length}.`); }
  }

  const done = loadDone();
  const todo = cohort.filter(id => !done.has(id));
  console.log(`Already done (ledger): ${cohort.length - todo.length}. To process: ${todo.length}.\n`);

  let written = 0, skipped = 0, failed = 0;
  const errorRows = [];
  const isValidation = !!limit || !!ids;

  for (let i = 0; i < todo.length; i++) {
    const PID = todo[i];
    const res = await writeProduct(PID);
    if (!res.ok) {
      failed++; errorRows.push({ PID, err: res.err });
      console.log(`  [${i + 1}/${todo.length}] FAIL ${PID} :: ${res.err}`);
      await sleep(600);
      continue;
    }
    // verify-on-write for validation runs (and a light verify every 100 in bulk)
    if (isValidation || (i % 100 === 0)) {
      await sleep(350);
      const v = await fetchVerify(PID);
      const mo = v?.mfs['material_options'];
      let parseOk = false, len = 0, parsed = null;
      if (mo) { try { parsed = JSON.parse(mo.value); parseOk = Array.isArray(parsed); len = parsed.length; } catch {} }
      const cost = v?.mfs['cost_per_m2'];
      const ok = mo && mo.type === 'json' && parseOk && len === 3;
      if (!ok) {
        failed++; errorRows.push({ PID, err: 'verify failed: material_options not valid json/3-entries' });
        console.log(`  [${i + 1}/${todo.length}] VERIFY-FAIL ${PID}`);
        await sleep(500);
        continue;
      }
      markDone(PID); written++;
      console.log(`  [${i + 1}/${todo.length}] OK ${PID} | "${v.title}"`);
      console.log(`     custom.material_options = ${mo.value}`);
      console.log(`     custom.materials_available present | cost_per_m2=${cost ? cost.value : '(none)'} (untouched)`);
    } else {
      markDone(PID); written++;
      if (i % 50 === 0) console.log(`  [${i + 1}/${todo.length}] OK ${PID}`);
    }
    await sleep(500); // ~2 req/s
  }

  console.log(`\n==== SUMMARY ====`);
  console.log(`written: ${written}  |  failed: ${failed}  |  total in this run: ${todo.length}`);
  if (errorRows.length) {
    console.log(`errors:`);
    for (const e of errorRows) console.log(`  ${e.PID} :: ${e.err}`);
  }
  const totalDone = loadDone().size;
  console.log(`progress ledger total DONE: ${totalDone}`);
  if (failed > 0) process.exit(1);
}

// ---------- arg parse ----------
function parseArgs() {
  const a = process.argv.slice(2);
  const out = { limit: null, ids: null, requery: null };
  for (let i = 0; i < a.length; i++) {
    if (a[i] === '--limit') out.limit = parseInt(a[++i], 10);
    else if (a[i] === '--ids') out.ids = a[++i].split(',').map(s => s.trim()).filter(Boolean).map(s => /^gid:\/\//.test(s) ? s : `gid://shopify/Product/${s.replace(/\D/g, '')}`);
    else if (a[i] === '--requery') out.requery = parseInt(a[++i], 10) || 15;
  }
  return out;
}

(async () => {
  const args = parseArgs();
  if (args.requery) { await runRequery(args.requery); return; }
  await runWrite(args);
})().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });