← back to Trending Dw

scripts/push-to-shopify.js

82 lines

#!/usr/bin/env node
// push-to-shopify.js — create real DW Shopify products from the staged DWPV queue.
//
// SAFE BY DEFAULT: refuses to run unless SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN are set,
// and creates every product as status:'draft' (NOT published) so nothing goes
// customer-facing without an explicit publish. Live publish stays a Steve-gated step.
//
//   env SHOPIFY_STORE=xxxx.myshopify.com SHOPIFY_ADMIN_TOKEN=shpat_… \
//     node scripts/push-to-shopify.js            # push all 'staged'
//   ... --dry                                     # print payloads, no API calls
//
// Idempotent: only pushes queue items with status==='staged'; on success flips them
// to 'pushed' + records shopifyId + handle, so re-runs never double-create.

const fs = require('fs');
const path = require('path');
const https = require('https');

const QUEUE = path.join(__dirname, '..', 'data', 'shopify-queue.json');
const STORE = process.env.SHOPIFY_STORE;                 // xxxx.myshopify.com
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;           // shpat_…
const API_VER = process.env.SHOPIFY_API_VERSION || '2024-01';
const DRY = process.argv.includes('--dry');

if (!DRY && (!STORE || !TOKEN)){
  console.error('REFUSING TO RUN: set SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN (route via /secrets). Use --dry to preview payloads.');
  process.exit(2);
}

function priceFromTier(tier){                            // "Commercial — $495" -> "495.00"
  const m = String(tier || '').match(/\$\s*([\d,]+)/);
  return m ? m[1].replace(/,/g, '') + '.00' : '149.00';
}

function buildProduct(p){
  return { product: {
    title: p.title,
    body_html: `<p>${p.style} · ${p.color}. Original Designer Wallcoverings pattern (Pattern Vault). Trend signal: ${p.trendSignal || ''} (rank ${p.signalRank || ''}).</p>`,
    vendor: p.vendor || 'Designer Wallcoverings',
    product_type: 'Wallcovering',
    status: 'draft',                                     // never auto-publish
    tags: [p.series, 'Pattern Vault', 'AI-Original', p.style, p.color].filter(Boolean).join(', '),
    variants: [{ sku: p.sku, price: priceFromTier(p.priceTier), inventory_management: null }],
    images: p.image ? [{ src: p.image }] : []
  }};
}

function apiPost(bodyObj){
  return new Promise((resolve, reject) => {
    const body = JSON.stringify(bodyObj);
    const req = https.request({
      hostname: STORE, path: `/admin/api/${API_VER}/products.json`, method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) }
    }, res => {
      let s = ''; res.on('data', d => s += d); res.on('end', () => {
        if (res.statusCode >= 200 && res.statusCode < 300) resolve(JSON.parse(s));
        else reject(new Error(`HTTP ${res.statusCode}: ${s.slice(0, 300)}`));
      });
    });
    req.on('error', reject); req.write(body); req.end();
  });
}

async function main(){
  const q = JSON.parse(fs.readFileSync(QUEUE, 'utf8'));
  const todo = q.filter(p => p.status === 'staged');
  console.log(`${todo.length} staged product(s) to push${DRY ? ' (DRY RUN)' : ` to ${STORE} as DRAFT`}`);
  let ok = 0;
  for (const p of todo){
    const payload = buildProduct(p);
    if (DRY){ console.log(`  ${p.sku}  ${p.title}  $${payload.product.variants[0].price}`); continue; }
    try {
      const r = await apiPost(payload);
      p.status = 'pushed'; p.shopifyId = r.product.id; p.handle = r.product.handle; p.pushedAt = new Date(Date.now()).toISOString();
      ok++; console.log(`  ✓ ${p.sku} -> product ${r.product.id} (draft)`);
    } catch (e){ console.error(`  ✗ ${p.sku}: ${e.message}`); }
  }
  if (!DRY){ fs.writeFileSync(QUEUE, JSON.stringify(q, null, 2)); console.log(`pushed ${ok}/${todo.length} as DRAFT — review + publish in Shopify admin.`); }
}

main().catch(e => { console.error(e); process.exit(1); });