← back to Sister Parish Onboarding

scripts/push_shopify.js

185 lines

#!/usr/bin/env node
/**
 * Push Sister Parish prints to DW Shopify as DRAFT.
 * - vendor=Sister Parish, product_type=Wallpaper, status=draft
 * - compare_at_price = SP retail; price = SP retail / 0.85
 * - inventory: qty=2026, tracked, policy=continue (per Steve's standing rules)
 * - SKU prefix DWSP-
 * - Image uploaded from src URL (Shopify pulls + rehosts)
 * - Records shopify_product_id back into dw_unified.sisterparish_catalog
 */
require('dotenv').config({ path: require('path').join(__dirname,'..','.env') });
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const SHOP = process.env.SHOPIFY_STORE;
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
if (!SHOP || !TOKEN) { console.error('Need SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN in .env'); process.exit(1); }

const BASE = `https://${SHOP}/admin/api/2026-01`;
const RATE_DELAY_MS = 350;  // ~3 req/s, well under DW Plus 4 req/s

const data = JSON.parse(fs.readFileSync(path.join(__dirname,'..','output','normalized.json'),'utf8'));

const ARGS = new Set(process.argv.slice(2));
const DRY = ARGS.has('--dry-run');
const LIMIT = (() => {
  const i = process.argv.indexOf('--limit');
  return i > -1 ? parseInt(process.argv[i+1],10) : null;
})();

async function sleep(ms) { return new Promise(r=>setTimeout(r,ms)); }

async function shopify(method, pathSuffix, body) {
  const url = `${BASE}${pathSuffix}`;
  const res = await fetch(url, {
    method,
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined
  });
  const text = await res.text();
  let json;
  try { json = JSON.parse(text); } catch { json = { raw: text }; }
  if (!res.ok) {
    const err = new Error(`Shopify ${method} ${pathSuffix} → ${res.status}: ${text.slice(0,400)}`);
    err.status = res.status; err.body = json;
    throw err;
  }
  return json;
}

function buildProductPayload(p) {
  // Option axes — collapse to (Pattern, Material, Size) like the source.
  const allOpts = p.variants.map(v => ({ o1: v.option1, o2: v.option2, o3: v.option3 }));
  const hasOpt1 = allOpts.some(v => v.o1);
  const hasOpt2 = allOpts.some(v => v.o2);
  const hasOpt3 = allOpts.some(v => v.o3);
  const options = [];
  if (hasOpt1) options.push({ name: 'Pattern' });
  if (hasOpt2) options.push({ name: 'Material' });
  if (hasOpt3) options.push({ name: 'Size' });

  const variants = p.variants.map(v => {
    const variant = {
      sku: `DWSP-${v.sku || ''}`,
      price: v.dw_retail.toFixed(2),
      compare_at_price: v.vendor_retail.toFixed(2),
      inventory_management: 'shopify',
      inventory_policy: 'continue',
      requires_shipping: true,
      taxable: true,
      weight: (v.grams || 0) / 1000,
      weight_unit: 'kg',
    };
    if (hasOpt1) variant.option1 = v.option1 || null;
    if (hasOpt2) variant.option2 = v.option2 || null;
    if (hasOpt3) variant.option3 = v.option3 || null;
    return variant;
  });

  const tags = [...new Set([
    ...(p.tags || []),
    'Sister Parish',
    'Wallcovering',
    'Wallpaper',
    'Print'
  ])].join(', ');

  return {
    product: {
      title: p.title,
      body_html: p.body_html || '',
      vendor: 'Sister Parish',
      product_type: 'Wallpaper',
      status: 'draft',          // standing rule: never ACTIVE without image-gate review
      tags,
      handle: `sp-${p.handle}`,
      options: options.length ? options : undefined,
      variants,
      images: (p.images || []).slice(0, 10).map(i => ({ src: i.src, position: i.position, alt: i.alt || p.title })),
      metafields: [
        { namespace: 'dwc', key: 'source_url', type: 'single_line_text_field', value: p.url },
        { namespace: 'dwc', key: 'source_product_id', type: 'single_line_text_field', value: String(p.sp_product_id) },
        { namespace: 'dwc', key: 'pricing_rule', type: 'single_line_text_field', value: 'vendor_retail / 0.85' },
      ]
    }
  };
}

async function setVariantInventory(variantId, qty) {
  // Variant inventory item → set qty at DW's default location.
  // 1) Get variant → inventory_item_id
  const vRes = await shopify('GET', `/variants/${variantId}.json`);
  const inventoryItemId = vRes.variant.inventory_item_id;
  // 2) Get default location
  const locs = await shopify('GET', '/locations.json');
  const locationId = locs.locations[0].id;
  // 3) Connect inventory_item to location (idempotent)
  try {
    await shopify('POST', '/inventory_levels/connect.json', { location_id: locationId, inventory_item_id: inventoryItemId });
  } catch (e) { /* already connected — ignore */ }
  // 4) Set qty
  await shopify('POST', '/inventory_levels/set.json', {
    location_id: locationId, inventory_item_id: inventoryItemId, available: qty
  });
}

(async () => {
  const products = LIMIT ? data.slice(0, LIMIT) : data;
  console.log(`${DRY ? '[DRY-RUN] ' : ''}Pushing ${products.length} Sister Parish products as DRAFT…`);

  const created = [];
  const failed = [];

  for (let i = 0; i < products.length; i++) {
    const p = products[i];
    const payload = buildProductPayload(p);
    const stamp = `[${String(i+1).padStart(3,'0')}/${products.length}]`;
    if (DRY) {
      console.log(`${stamp} would create: ${p.title} (${payload.product.variants.length} variants, ${payload.product.images.length} images)`);
      continue;
    }
    try {
      const r = await shopify('POST', '/products.json', payload);
      const prod = r.product;
      created.push({ sp_id: p.sp_product_id, sf_id: prod.id, handle: prod.handle, variants: prod.variants.length });
      console.log(`${stamp} ✓ ${p.title}  → SF #${prod.id}  (${prod.variants.length} variants, ${prod.images.length} images)`);

      // Set inventory qty=2026 on every variant (per standing rule)
      for (const v of prod.variants) {
        try {
          await setVariantInventory(v.id, 2026);
          await sleep(RATE_DELAY_MS);
        } catch (invErr) {
          console.log(`     · variant ${v.id} inventory set failed: ${invErr.message.slice(0,140)}`);
        }
      }
    } catch (e) {
      failed.push({ sp_id: p.sp_product_id, title: p.title, err: e.message });
      console.log(`${stamp} ✗ ${p.title}: ${e.message.slice(0,200)}`);
    }
    await sleep(RATE_DELAY_MS);
  }

  console.log(`\nCreated: ${created.length}   Failed: ${failed.length}`);
  fs.writeFileSync(path.join(__dirname,'..','output','created.json'), JSON.stringify(created, null, 2));
  if (failed.length) fs.writeFileSync(path.join(__dirname,'..','output','failed.json'), JSON.stringify(failed, null, 2));

  // Write back shopify_product_id into PG.
  if (created.length) {
    const lines = created.map(c =>
      `UPDATE sisterparish_catalog SET source_url = COALESCE(source_url, '') WHERE sp_product_id=${c.sp_id};`
    );
    // Add shopify_product_id column if not exists, then update.
    const sql = `
ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS shopify_product_id BIGINT;
ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS shopify_handle TEXT;
${created.map(c => `UPDATE sisterparish_catalog SET shopify_product_id=${c.sf_id}, shopify_handle='${c.handle.replace(/'/g,"''")}' WHERE sp_product_id=${c.sp_id};`).join('\n')}
SELECT COUNT(*) AS with_shopify FROM sisterparish_catalog WHERE shopify_product_id IS NOT NULL;
`;
    fs.writeFileSync('/tmp/sp_pg_update.sql', sql);
    execSync('psql dw_unified -f /tmp/sp_pg_update.sql', { stdio: 'inherit' });
  }
})().catch(e => { console.error('FATAL', e); process.exit(1); });