← back to Designerwallcoverings

scripts/artmura-onboard/backfill_metafields.js

81 lines

#!/usr/bin/env node
/**
 * Backfill Artmura product metafields with the CORRECT per-key types (the create 422'd
 * because custom.material=multi_line_text_field and custom.collection/lead_time=product_reference).
 * Runs AFTER push-artmura-live.js (reads shopify_product_id from newwall_catalog).
 *
 *   node backfill_metafields.js            # dry-run (first 3)
 *   node backfill_metafields.js --apply
 */
const https = require('https');
const fs = require('fs');
const path = require('path');
const { Pool } = 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 = process.env.SHOPIFY_API_VERSION || '2024-10';
const DB = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
const APPLY = process.argv.includes('--apply');
const PKG = path.join(__dirname, 'data', 'artmura.json');
if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
const pool = new Pool({ connectionString: DB });
const sleep = ms => new Promise(r => setTimeout(r, ms));

function shopify(method, p, body) {
  return new Promise((resolve, reject) => {
    const data = body ? JSON.stringify(body) : null;
    const req = https.request({ hostname: STORE, path: `/admin/api/${API}${p}`, method,
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json',
                 ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}) } },
      res => { let b = ''; res.on('data', c => b += c); res.on('end', () => { let j; try { j = JSON.parse(b); } catch { j = b; }
        resolve({ status: res.statusCode, body: j, callLimit: res.headers['x-shopify-shop-api-call-limit'] }); }); });
    req.on('error', reject); req.setTimeout(20000, () => req.destroy(new Error('timeout')));
    if (data) req.write(data); req.end();
  });
}

// key -> type, matching the store's existing definitions. SKIP collection + lead_time
// (defined as product_reference — cannot hold our text).
const FIELDS = [
  ['design_name', 'single_line_text_field', r => r.pattern_series],
  ['colorway_name', 'single_line_text_field', r => r.color],
  ['material', 'multi_line_text_field', r => r.substrate || 'Non-Woven'],
  ['width', 'single_line_text_field', r => r.dimensions],
  ['wall_coverage', 'single_line_text_field', r => r.wall_coverage],
  ['origin', 'single_line_text_field', r => r.origin],
  ['grade', 'single_line_text_field', r => r.grade],
  ['mfr_sku', 'single_line_text_field', r => r.mfr_sku],
  ['source_url', 'single_line_text_field', r => r.product_url],
];

(async () => {
  const pkg = JSON.parse(fs.readFileSync(PKG, 'utf8')).products;
  const { rows } = await pool.query(
    `SELECT handle, shopify_product_id FROM newwall_catalog WHERE vendor_name='Artmura' AND shopify_product_id IS NOT NULL AND shopify_product_id<>''`);
  const pid = Object.fromEntries(rows.map(r => [r.handle, r.shopify_product_id]));
  const todo = pkg.filter(p => pid[p.handle]);
  console.log(`metafield backfill: ${todo.length} products · mode: ${APPLY ? 'APPLY' : 'DRY-RUN'}`);

  if (!APPLY) {
    const r = todo[0]; const mfs = FIELDS.map(([k, t, f]) => ({ key: k, type: t, value: f(r) })).filter(m => m.value);
    console.log(`example ${pid[r.handle]}:`); mfs.forEach(m => console.log(`  custom.${m.key} (${m.type}) = ${m.value}`));
    console.log(`\nDRY-RUN. --apply to write.`); await pool.end(); return;
  }

  let ok = 0, fail = 0;
  for (const r of todo) {
    const id = pid[r.handle];
    const metafields = FIELDS.map(([k, t, f]) => { const v = f(r); return v ? { namespace: 'custom', key: k, type: t, value: String(v) } : null; }).filter(Boolean);
    try {
      const res = await shopify('PUT', `/products/${id}.json`, { product: { id: Number(id), metafields } });
      if (res.status === 200) { ok++; if (ok % 40 === 0) console.log(`  ...${ok}`); }
      else { fail++; console.error(`  FAIL ${id}: HTTP ${res.status} ${JSON.stringify(res.body).slice(0, 160)}`); }
      const [used] = (res.callLimit || '0/40').split('/').map(Number);
      await sleep(used > 30 ? 1500 : 540);
    } catch (e) { fail++; console.error(`  ERR ${id}: ${e.message}`); await sleep(540); }
  }
  console.log(`\nDONE. metafields written=${ok} failed=${fail}`);
  await pool.end();
})().catch(e => { console.error(e); process.exit(1); });