← back to Sister Parish Onboarding

scripts/push_shopify.js

243 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 { assertNoPublicLeak } = require(path.join(process.env.HOME, 'Projects/_shared/leak-guard/leak-guard.cjs')); // TK-12032 fail-closed private-label leak guard

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'));
// TK-11024: width metafield (Carnegie-class). Width isn't in normalized.json; it lives in the
// per-PDP crawl (pdp_specs.json → canon.width), keyed by sp_product_id (same source that fills
// sisterparish_catalog.specs.width). Build a lookup; missing file → empty map (no-op).
let widthBySpId = new Map();
try {
  const pdp = JSON.parse(fs.readFileSync(path.join(__dirname,'..','output','pdp_specs.json'),'utf8'));
  for (const r of pdp) {
    const w = r && r.canon && r.canon.width != null ? String(r.canon.width).trim() : '';
    if (w) widthBySpId.set(String(r.sp_product_id), w);
  }
} catch { /* pdp_specs.json absent → no width metafield, same as today */ }

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;
}

const { safeStampQuantity } = require('./lib/inventory-stamp-guard.mjs');  // GUARD TK-11357 (shared guard)
// ── GUARD TK-11357 BEGIN ─ do not edit without re-running the fixture proof ──────────
// A $0 / quote-only sellable variant must NEVER receive positive stock: positive stock is what
// flips availableForSale=true, making it checkout-orderable at $0 (lineage TK-10825 -> 10965 ->
// 11140 -> 11299 -> 11301 -> 11357). $0 is the LIVE theme's deliberate quote-only SENTINEL
// (snippets/product-form-content.liquid renders the "Contact Us" button iff variant.price == 0),
// so the remedy is NEVER to write a placeholder price - it is "do not stock it".
// Steve's 2026-06-20 "active products are never out of stock" rule is PRESERVED for PRICED goods:
// a priced variant still gets `desired`. The quote-only tag/vendor decision is delegated to the
// shared guard (inventory-stamp-guard.mjs); this adds one strictly-safer rule of its own -
// price <= 0 / NaN is ALWAYS 0, even on a variant labelled "Sample" (a $0 "sample" is the same
// $0-orderable defect). The real $4.25 memo sample is unaffected and keeps its existing quantity.
// `inventoryItem?.id ?? null` because this writer decides on a CREATE PAYLOAD, where no
// inventoryItem id exists yet; only the .quantity is consumed there.
function safeQuantities(product, variants, locationId, desired) {
  return (variants || []).map(v => ({
    inventoryItemId: v.inventoryItem?.id ?? null,
    locationId,
    quantity: Number(v.price) > 0
      ? safeStampQuantity({ title: v.title, price: v.price }, product, desired)
      : 0,
  }));
}
// inventoryPolicy CONTINUE (oversell) makes a variant orderable at ANY quantity INCLUDING ZERO.
// So for the $0 class, "set the quantity to 0" is a NO-OP under CONTINUE and the entire
// established remedy for this defect class silently fails. A variant the guard zeroes must
// therefore ALSO be DENY, or the guard is decorative. Priced variants keep the caller's policy
// exactly as-is, so oversell behaviour for real made-to-order goods is unchanged.
function safePolicy(product, variant, desiredPolicy, desired) {
  return safeQuantities(product, [variant], null, desired)[0].quantity > 0 ? desiredPolicy : 'deny';
}
// ── GUARD TK-11357 END ────────────────────────────────────────────

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',
      // GUARD TK-11357: dw_retail comes straight from the input feed with no price check, so a
      // 0/absent vendor_retail minted a $0 variant with policy CONTINUE — orderable at ANY
      // quantity INCLUDING ZERO, which defeats the qty->0 remedy for this defect class.
      inventory_policy: safePolicy({ vendor: 'Sister Parish', tags: [] }, { title: v.option3 || v.option1, price: v.dw_retail }, 'continue', 2026),
      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' },
        ...(widthBySpId.get(String(p.sp_product_id))
          ? [{ namespace: 'global', key: 'width', type: 'single_line_text_field', value: widthBySpId.get(String(p.sp_product_id)) }]
          : []),
      ]
    }
  };
}

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
  // GUARD TK-11357: never stamp positive stock without a price — callers must pass the guard's
  // safe quantity (a $0 variant reaching this with a positive qty is the whole defect class).
  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);
    try { assertNoPublicLeak(payload, { context: `sister-parish ${p.sp_product_id}` }); }
    catch (e) { console.error(`  ⛔ LEAK GUARD ${p.sp_product_id}: ${e.message}`); failed.push({ sp_id: p.sp_product_id, error: e.message }); continue; }
    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 PRICED variant (standing rule), per GUARD TK-11357:
      // stock from the price Shopify actually landed, never a flat 2026 — a $0 variant gets 0.
      for (const q of safeQuantities({ vendor: 'Sister Parish', tags: [] },
             prod.variants.map(v => ({ title: v.option3 || v.option1, price: v.price, inventoryItem: { id: v.id } })), null, 2026)) {
        if (q.quantity === 0) { console.log(`     · ⛔ variant ${q.inventoryItemId}: $0 — NOT stocked (TK-11357 guard)`); continue; }
        try {
          await setVariantInventory(q.inventoryItemId, q.quantity);
          await sleep(RATE_DELAY_MS);
        } catch (invErr) {
          console.log(`     · variant ${q.inventoryItemId} 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); });