[object Object]

← back to Letsbegin

Write C3 per-m2 cost ($44.50) metafield on 1000 Rebel Walls murals (portal-confirmed Net)

62605f82f723a9867418ac5f4f4d94bf3300c5c7 · 2026-06-11 14:44:56 -0700 · Steve Abrams

Files touched

Diff

commit 62605f82f723a9867418ac5f4f4d94bf3300c5c7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 11 14:44:56 2026 -0700

    Write C3 per-m2 cost ($44.50) metafield on 1000 Rebel Walls murals (portal-confirmed Net)
---
 rw-cost-write.js | 238 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 238 insertions(+)

diff --git a/rw-cost-write.js b/rw-cost-write.js
new file mode 100644
index 0000000..0ef488b
--- /dev/null
+++ b/rw-cost-write.js
@@ -0,0 +1,238 @@
+#!/usr/bin/env node
+/**
+ * rw-cost-write.js — Write the portal-confirmed C3 per-m² COST metafield onto
+ * every Rebel Walls custom mural in the sandbox store.
+ *
+ * Steve approved ("go"). Cost confirmed live from partner.gimmersta.com trade
+ * portal (Rebel Walls shares Sandberg/Gimmersta group price list):
+ *   C3 standard non-woven: RRP $76.40/m² → Net (our cost, excl tax) = $44.50/m².
+ *
+ * Writes (namespace `custom`) on each product:
+ *   - custom.cost_per_m2   = 44.50   (number_decimal — no existing def, safe)
+ *   - custom.cost_currency = USD     (single_line_text_field)
+ *   - custom.price_group   = C3      (single_line_text_field)
+ *   - custom.cost_basis    = <provenance string> (multi_line_text_field)
+ *
+ * Does NOT touch: color/title/tag metafields, Shopify variant price/compare-at,
+ * or any non-RW product. Cohort is locked by the dw_unified query below.
+ *
+ * Idempotent + resumable: re-fetches each product's existing custom.* metafields
+ * first; if all 4 already match the target values it SKIPs the write. So a
+ * re-run only writes the rows that aren't already correct.
+ *
+ * Rate-limited (~2 req/s) with the same Throttled-backoff as enrich-write.js.
+ *
+ * Usage:
+ *   node rw-cost-write.js --validate        # write+verify first 3 only, print before/after
+ *   node rw-cost-write.js                    # run all 1000
+ *   node rw-cost-write.js --limit N          # run first N
+ *   node rw-cost-write.js --reqa             # re-fetch ~15 and print cost_per_m2/price_group, no writes
+ *
+ * Env: SHOPIFY_ADMIN_TOKEN (loaded from secrets-manager/.env). PG defaults
+ *      user stevestudio2, db dw_unified, local socket.
+ */
+
+const https = require('https');
+const fs = require('fs');
+const { Client } = require('pg');
+
+// ---------- env (parse secrets-manager/.env; zsh-source-unsafe, so do it here) ----------
+function loadEnv(path) {
+  try {
+    for (const line of fs.readFileSync(path, 'utf8').split('\n')) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
+      if (!m) continue;
+      let v = m[2].trim();
+      if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+      if (process.env[m[1]] == null) process.env[m[1]] = v;
+    }
+  } catch { /* file optional if env already set */ }
+}
+loadEnv('/Users/stevestudio2/Projects/secrets-manager/.env');
+
+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); }
+
+// HARD GUARD: sandbox store only.
+if (STORE !== 'designer-laboratory-sandbox.myshopify.com') {
+  console.error(`FATAL: refusing to run against non-sandbox store "${STORE}"`); process.exit(1);
+}
+
+// ---------- target values (portal-confirmed) ----------
+const COST_PER_M2 = '44.50';
+const COST_CURRENCY = 'USD';
+const PRICE_GROUP = 'C3';
+const COST_BASIS = 'C3 standard non-woven; Net excl tax from partner.gimmersta.com 2026-06; RRP $76.40/m2; ~41.8% off RRP. Provisional — C2/C4/P&S material variants pending portal Net.';
+
+// ---------- cli ----------
+const args = process.argv.slice(2);
+const VALIDATE = args.includes('--validate');
+const REQA = args.includes('--reqa');
+const limIdx = args.indexOf('--limit');
+const LIMIT = limIdx >= 0 ? parseInt(args[limIdx + 1], 10) : null;
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// ---------- Shopify GraphQL (gqlRaw/gql backoff lifted 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(3000); 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 } } }';
+
+// Fetch only the 4 cost-related custom metafields for a product (cheap, targeted).
+async function fetchCost(pid) {
+  const r = await gql({ query: `{ product(id:"${pid}") {
+    id title vendor
+    mfA: metafield(namespace:"custom", key:"cost_per_m2"){ value }
+    mfB: metafield(namespace:"custom", key:"cost_currency"){ value }
+    mfC: metafield(namespace:"custom", key:"price_group"){ value }
+    mfD: metafield(namespace:"custom", key:"cost_basis"){ value }
+  } }` });
+  const p = r?.data?.product;
+  if (!p) return null;
+  return {
+    id: p.id, title: p.title, vendor: p.vendor,
+    cost_per_m2: p.mfA?.value ?? null,
+    cost_currency: p.mfB?.value ?? null,
+    price_group: p.mfC?.value ?? null,
+    cost_basis: p.mfD?.value ?? null,
+  };
+}
+
+function alreadyCorrect(c) {
+  // number_decimal normalizes "44.50" -> "44.5"; accept either spelling.
+  const numOk = c.cost_per_m2 === COST_PER_M2 || c.cost_per_m2 === '44.5';
+  return numOk && c.cost_currency === COST_CURRENCY && c.price_group === PRICE_GROUP && c.cost_basis === COST_BASIS;
+}
+
+async function writeCost(pid) {
+  const mf = [
+    { ownerId: pid, namespace: 'custom', key: 'cost_per_m2', value: COST_PER_M2, type: 'number_decimal' },
+    { ownerId: pid, namespace: 'custom', key: 'cost_currency', value: COST_CURRENCY, type: 'single_line_text_field' },
+    { ownerId: pid, namespace: 'custom', key: 'price_group', value: PRICE_GROUP, type: 'single_line_text_field' },
+    { ownerId: pid, namespace: 'custom', key: 'cost_basis', value: COST_BASIS, type: 'multi_line_text_field' },
+  ];
+  const r = await gql({ query: MF_MUTATION, variables: { m: mf } });
+  const errs = r?.data?.metafieldsSet?.userErrors || [];
+  return errs.map(e => `${(e.field || []).join('.')}: ${e.message}`);
+}
+
+// ---------- cohort from dw_unified (RW murals, last 20 days) ----------
+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'
+       ORDER BY shopify_id`
+    );
+    return res.rows.map(r => r.shopify_id).filter(Boolean);
+  } finally { await pg.end(); }
+}
+
+async function main() {
+  let ids = await loadCohort();
+  console.log(`[rw-cost-write] cohort = ${ids.length} Rebel Walls murals (sandbox)`);
+  if (ids.length === 0) { console.error('FATAL: cohort empty'); process.exit(1); }
+
+  // ---- --reqa: re-fetch a 15-item sample, print, no writes ----
+  if (REQA) {
+    const step = Math.max(1, Math.floor(ids.length / 15));
+    const sample = ids.filter((_, i) => i % step === 0).slice(0, 15);
+    console.log(`[re-QA] sampling ${sample.length} products...`);
+    let ok = 0, bad = 0;
+    for (const pid of sample) {
+      const c = await fetchCost(pid);
+      const good = c && (c.cost_per_m2 === '44.5' || c.cost_per_m2 === '44.50') && c.price_group === 'C3';
+      if (good) ok++; else bad++;
+      console.log(`  ${good ? 'OK ' : 'BAD'} ${pid}  cost_per_m2=${c?.cost_per_m2}  price_group=${c?.price_group}  cur=${c?.cost_currency}  vendor=${c?.vendor}`);
+      await sleep(450);
+    }
+    console.log(`[re-QA] ${ok}/${sample.length} confirmed cost_per_m2=44.50 & price_group=C3, ${bad} bad`);
+    return;
+  }
+
+  if (VALIDATE) ids = ids.slice(0, 3);
+  else if (LIMIT) ids = ids.slice(0, LIMIT);
+
+  let written = 0, skipped = 0, failed = 0;
+  const failures = [];
+
+  for (let i = 0; i < ids.length; i++) {
+    const pid = ids[i];
+    const before = await fetchCost(pid);
+    if (!before) { failed++; failures.push(`${pid}: product not found`); console.log(`  [${i + 1}/${ids.length}] ${pid} NOT FOUND`); continue; }
+
+    // Guardrail: never touch a non-RW product even if the cohort query drifted.
+    if (before.vendor !== 'Rebel Walls') {
+      failed++; failures.push(`${pid}: vendor="${before.vendor}" not Rebel Walls — skipped`);
+      console.log(`  [${i + 1}/${ids.length}] ${pid} GUARD: vendor="${before.vendor}" not RW, skipped`);
+      continue;
+    }
+
+    if (alreadyCorrect(before)) {
+      skipped++;
+      if (VALIDATE || i % 100 === 0) console.log(`  [${i + 1}/${ids.length}] ${pid} already correct, skip`);
+      continue;
+    }
+
+    const errs = await writeCost(pid);
+    await sleep(350); // ~2 req/s (fetch + write per item)
+
+    if (errs.length) {
+      failed++; failures.push(`${pid}: ${errs.join('; ')}`);
+      console.log(`  [${i + 1}/${ids.length}] ${pid} ERROR: ${errs.join('; ')}`);
+      continue;
+    }
+
+    if (VALIDATE) {
+      const after = await fetchCost(pid);
+      console.log(`  VALIDATE ${pid}`);
+      console.log(`    BEFORE: cost_per_m2=${before.cost_per_m2}  price_group=${before.price_group}  cur=${before.cost_currency}`);
+      console.log(`    AFTER : cost_per_m2=${after.cost_per_m2}  price_group=${after.price_group}  cur=${after.cost_currency}  basis=${after.cost_basis ? 'set' : 'MISSING'}`);
+      const ok = (after.cost_per_m2 === '44.5' || after.cost_per_m2 === '44.50') && after.price_group === 'C3' && after.cost_currency === 'USD' && !!after.cost_basis;
+      if (!ok) { failed++; failures.push(`${pid}: post-write verify failed`); }
+      else written++;
+      await sleep(350);
+    } else {
+      written++;
+      if (i % 50 === 0 || i === ids.length - 1) console.log(`  [${i + 1}/${ids.length}] progress: written=${written} skipped=${skipped} failed=${failed}`);
+    }
+  }
+
+  console.log(`\n=== DONE (${VALIDATE ? 'VALIDATE' : 'FULL'}) ===`);
+  console.log(`cohort=${ids.length}  written=${written}  skipped(already-correct)=${skipped}  failed=${failed}`);
+  if (failures.length) { console.log('FAILURES:'); failures.slice(0, 30).forEach(f => console.log('  - ' + f)); }
+  if (failed > 0) process.exit(1);
+}
+
+main().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });

← 857b11f Class-wide tag/entity cleanup on 1003-product cohort (decode  ·  back to Letsbegin  ·  Backfill SKUs (mfr_sku/dw_sku/variant sku + rw url metafield 3c0c7fa →