← 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 => ({ '&':'&','<':'<','>':'>','"':'"' }[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); });