← back to Designerwallcoverings

scripts/artmura-onboard/push-artmura-live.js

130 lines

#!/usr/bin/env node
/**
 * Direct Shopify Admin REST creator for Artmura (DW onboard, 2026-06-12).
 * Models the proven thibaut-mfr-fix/push-thibaut-live.js path (the queue worker is dead).
 * Creates products as DRAFT on the LIVE store (designer-laboratory-sandbox = prod).
 *
 * Pricing: website price, NO trade discount (Steve directive). Yard variant = price_newwall_retail.
 * Idempotent: skips any handle that already has shopify_product_id in newwall_catalog.
 *
 * Usage (via run-push-artmura.sh which sets token/store/db):
 *   node push-artmura-live.js                      # DRY-RUN: show first 3 planned creates
 *   node push-artmura-live.js --apply --limit=5    # canary: create 5 drafts
 *   node push-artmura-live.js --apply              # create all remaining drafts
 *   node push-artmura-live.js --apply --status=active   # (only when Steve says go live)
 */
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 STATUS = (process.argv.find(a => a.startsWith('--status=')) || '--status=draft').split('=')[1];
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
const PKG = path.join(__dirname, 'data', 'artmura.json');

if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN (never hard-code).'); 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('request timeout')));
    if (data) req.write(data); req.end();
  });
}

const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));

function specTable(r) {
  const rows = [
    ['Material', r.substrate || 'Non-Woven'],
    ['Dimensions', r.dimensions],
    ['Wall coverage', r.wall_coverage],
    ['Grade', r.grade],
    ['Lead-time', r.lead_time],
    ['Origin', r.origin],
  ].filter(([, v]) => v);
  const trs = rows.map(([k, v]) =>
    `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n');
  return `<div class="artmura-product">\n${r.body_html || ('<p>' + esc(r.title) + ' by Artmura.</p>')}\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${trs}\n</table>\n</div>`;
}

function buildPayload(r, dwSku) {
  const title = `${r.title} Wallcovering | Artmura`;
  const tags = [r.pattern_series, r.collection_book, 'Wallcovering', 'Non-Woven', 'Made in Italy', 'Artmura', dwSku]
    .filter(Boolean).join(', ');
  const images = (r.images || []).map(src => ({ alt: esc(r.title), src }));
  // NOTE: no metafields on create — the store has existing custom.* definitions with
  // fixed types (material=multi_line_text_field, some keys=product_reference) and REST
  // create rejects (422) on any type mismatch. Specs live in body_html; metafields are
  // backfilled separately (backfill_metafields.js) with per-key type detection.
  return { product: {
    title, vendor: 'Artmura', product_type: r.product_type || 'Wallcoverings',
    status: STATUS, tags, images, body_html: specTable(r),
    options: [{ name: 'Size' }],
    variants: [
      { sku: dwSku, price: String(r.price_newwall_retail), option1: 'Yard', taxable: true, requires_shipping: true },
      { sku: `${dwSku}-Sample`, price: String(r.sample_price || 5), option1: 'Sample', taxable: true, requires_shipping: true },
    ],
  } };
}

(async () => {
  const pkg = JSON.parse(fs.readFileSync(PKG, 'utf8')).products;
  // join dw_sku + existing shopify_product_id from newwall_catalog by handle
  const { rows } = await pool.query(
    `SELECT handle, dw_sku, shopify_product_id FROM newwall_catalog WHERE vendor_name='Artmura'`);
  const byHandle = Object.fromEntries(rows.map(r => [r.handle, r]));
  let todo = pkg.filter(p => { const d = byHandle[p.handle]; return d && d.dw_sku && !(d.shopify_product_id && String(d.shopify_product_id).trim()); });
  if (LIMIT > 0) todo = todo.slice(0, LIMIT);

  const already = pkg.length - pkg.filter(p => { const d = byHandle[p.handle]; return d && d.dw_sku && !(d.shopify_product_id); }).length;
  console.log(`Store: ${STORE} · token …${TOKEN.slice(-4)} · status=${STATUS} · to create: ${todo.length} (already on shopify: ${already}) · mode: ${APPLY ? 'APPLY' : 'DRY-RUN'}`);

  if (!APPLY) {
    for (const r of todo.slice(0, 3)) {
      const d = byHandle[r.handle];
      const pl = buildPayload(r, d.dw_sku);
      console.log(`\n[create] ${d.dw_sku}  "${pl.product.title}"`);
      console.log(`   variants: Yard $${r.price_newwall_retail} / Sample $${r.sample_price || 5} · imgs:${pl.product.images.length} · tags: ${pl.product.tags}`);
    }
    console.log(`\nDRY-RUN only. Re-run with --apply (optionally --limit=N) to create drafts.`);
    await pool.end(); return;
  }

  let ok = 0, fail = 0;
  for (const r of todo) {
    const d = byHandle[r.handle];
    try {
      const res = await shopify('POST', `/products.json`, buildPayload(r, d.dw_sku));
      if (res.status === 201 && res.body?.product?.id) {
        ok++; const pid = res.body.product.id;
        await pool.query(`UPDATE newwall_catalog SET shopify_product_id=$1, on_shopify=$2, updated_at=now() WHERE handle=$3`,
          [String(pid), STATUS === 'active', r.handle]);
        await pool.query(`UPDATE dw_sku_registry SET shopify_product_id=$1 WHERE dw_sku=$2`, [String(pid), d.dw_sku]);
        console.log(`  ✓ ${d.dw_sku} → product ${pid} (${STATUS})`);
      } else {
        fail++; console.error(`  FAIL ${d.dw_sku}: HTTP ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`);
      }
      const [used] = (res.callLimit || '0/40').split('/').map(Number);
      await sleep(used > 30 ? 1600 : 560);
    } catch (e) { fail++; console.error(`  ERR ${d.dw_sku}: ${e.message}`); await sleep(560); }
  }
  console.log(`\nDONE. created=${ok} failed=${fail} of ${todo.length}`);
  await pool.end();
})().catch(e => { console.error(e); process.exit(1); });