← back to New Import Viewer

phase8-fix/shopify-push.js

204 lines

#!/usr/bin/env node
// Phase 8 REAL push module for full-monte (Kamatera /root/DW-Agents/full-monte/).
// PURPOSE: make Phase 8 actually CREATE + PUBLISH products, not just migrate
// metafields. Mirrors the canonical, publish-gap-fixed pattern from Mac2
// new-import-viewer/consumer.js (commit 96cd954):
//   • REST create (status active|draft)  → product exists
//   • GraphQL publishablePublish to Online Store  → product is VISIBLE
//   • mandatory $4.25 {dw_sku}-Sample variant
//   • THREE-SOURCE NEVER-DUPLICATE gate (shopify_products + dw_sku_registry, fresh)
//   • image + dw_sku + pattern required; no imageless / dup / leak creates
//
// Reads the UNIFIED vendor_catalog (source of truth, has all columns) filtered by
// vendor_code — NOT per-vendor <vendor>_catalog — so the dedup + column contract
// matches consumer.js exactly.
//
// Usage from Phase 8:  const { pushVendor } = require('./shopify-push');
//                      await pushVendor(vendorCode, { pool, token, store, limit, allowActive });
'use strict';

const SHOP_API = '2024-10';
const SAMPLE_PRICE = '4.25';

const VENDOR_DENY = new Set(['cowtan_tout']);
const VENDOR_HOLD = new Set(['command54']);
const PRIVATE_LABEL = { command54: 'Phillipe Romano' };
const titleCase = (v) => String(v || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());

function parsePrice(specs) {
  try {
    const s = typeof specs === 'string' ? JSON.parse(specs) : specs || {};
    for (const k of ['retail_price_usd', 'retail_price', 'list_price']) {
      const m = String(s[k] ?? '').match(/\d+(?:\.\d{1,2})?/);
      if (m && +m[0] > 0) return (+m[0]).toFixed(2);
    }
  } catch {}
  return null;
}
function parseImages(imageUrl, allImages) {
  const out = [];
  const push = (u) => { u = String(u || '').trim(); if (/^https?:\/\//.test(u) && !out.includes(u)) out.push(u); };
  push(imageUrl);
  if (allImages) {
    let list = [];
    try { list = JSON.parse(allImages); } catch { list = String(allImages).split(/[\n,|]+/); }
    if (Array.isArray(list)) list.forEach(push);
  }
  return out.slice(0, 6);
}

async function shopifyCreate(store, token, payload) {
  const url = `https://${store}/admin/api/${SHOP_API}/products.json`;
  for (let attempt = 1; attempt <= 5; attempt++) {
    const r = await fetch(url, {
      method: 'POST',
      headers: { 'X-Shopify-Access-Token': token, 'content-type': 'application/json' },
      body: JSON.stringify({ product: payload }),
    });
    if (r.status === 429) {
      const wait = (parseFloat(r.headers.get('retry-after')) || 2) * 1000;
      await new Promise(res => setTimeout(res, wait)); continue;
    }
    if (!r.ok) return { ok: false, error: `HTTP ${r.status} ${(await r.text()).slice(0, 200)}` };
    const j = await r.json().catch(() => ({}));
    return { ok: true, id: j.product?.id, handle: j.product?.handle };
  }
  return { ok: false, error: '429 retries exhausted (daily variant limit?)' };
}

// ── publish gate (the 96cd954 fix) ─────────────────────────────────────────
let ONLINE_STORE_PUB_ID = null;
async function gql(store, token, query, variables) {
  const url = `https://${store}/admin/api/${SHOP_API}/graphql.json`;
  for (let attempt = 1; attempt <= 5; attempt++) {
    const r = await fetch(url, {
      method: 'POST',
      headers: { 'X-Shopify-Access-Token': token, 'content-type': 'application/json' },
      body: JSON.stringify({ query, variables }),
    });
    if (r.status === 429) { await new Promise(res => setTimeout(res, 2000 * attempt)); continue; }
    const j = await r.json().catch(() => ({}));
    if (j.errors && /throttled/i.test(JSON.stringify(j.errors))) { await new Promise(res => setTimeout(res, 2000 * attempt)); continue; }
    return j;
  }
  return { errors: [{ message: 'gql 429 retries exhausted' }] };
}
async function resolveOnlineStorePub(store, token) {
  if (ONLINE_STORE_PUB_ID) return ONLINE_STORE_PUB_ID;
  const j = await gql(store, token, `{publications(first:25){edges{node{id name}}}}`);
  const edges = j.data?.publications?.edges || [];
  const os = edges.find(e => /online store/i.test(e.node.name));
  ONLINE_STORE_PUB_ID = os ? os.node.id : null;
  return ONLINE_STORE_PUB_ID;
}
async function publishOnlineStore(store, token, productId) {
  const pubId = await resolveOnlineStorePub(store, token);
  if (!pubId) return { ok: false, error: 'Online Store publication not found' };
  const gid = `gid://shopify/Product/${productId}`;
  const j = await gql(store, token,
    `mutation($id:ID!,$pub:ID!){publishablePublish(id:$id,input:[{publicationId:$pub}]){userErrors{field message}}}`,
    { id: gid, pub: pubId });
  if (j.errors) return { ok: false, error: JSON.stringify(j.errors).slice(0, 200) };
  const ue = (j.data?.publishablePublish?.userErrors || []).filter(e => !/already/i.test(e.message || ''));
  if (ue.length) return { ok: false, error: JSON.stringify(ue).slice(0, 200) };
  return { ok: true };
}

// Push one vendor's net-new rows. allowActive=false → DRAFT (no publish), the
// default safe mode; allowActive=true → ACTIVE + publishablePublish.
async function pushVendor(vendorCode, { pool, token, store, limit = 400, allowActive = false, log = console.log } = {}) {
  if (VENDOR_DENY.has(vendorCode)) { log(`  push: ${vendorCode} denylisted — skip`); return { created: 0, skipped: 0, failed: 0 }; }
  if (VENDOR_HOLD.has(vendorCode)) { log(`  push: ${vendorCode} on hold (brand ruling) — skip`); return { created: 0, skipped: 0, failed: 0 }; }
  if (!token) { log(`  push: no SHOPIFY_ADMIN_TOKEN — refuse`); return { created: 0, skipped: 0, failed: 0 }; }

  // Net-new, image-bearing, dedup-clean rows for this vendor, oldest-first.
  // THREE-SOURCE NEVER-DUPLICATE: not on shopify_products, not in dw_sku_registry.
  const { rows } = await pool.query(`
    SELECT vc.id, vc.vendor_code, vc.mfr_sku, vc.dw_sku, vc.pattern_name, vc.color_name,
           vc.collection, vc.product_type, vc.image_url, vc.all_images, vc.specs::text AS specs,
           vc.ai_description, vc.width, vc.original_vendor_name,
           vr.vendor_name, vr.is_private_label, vr.private_label_name, vr.skip_shopify
    FROM vendor_catalog vc
    LEFT JOIN vendor_registry vr ON vr.vendor_code = vc.vendor_code
    WHERE vc.vendor_code = $1
      AND (vc.sync_status='new' OR ((vc.on_shopify IS NOT TRUE) AND vc.shopify_product_id IS NULL))
      AND vc.shopify_product_id IS NULL AND vc.on_shopify IS NOT TRUE
      AND vc.mfr_sku IS NOT NULL AND vc.mfr_sku <> ''
      AND vc.image_url IS NOT NULL AND vc.image_url <> ''
      AND vc.dw_sku IS NOT NULL AND vc.pattern_name IS NOT NULL
      AND COALESCE(vr.skip_shopify, FALSE) = FALSE
      AND NOT (vr.is_private_label = TRUE AND (vr.private_label_name IS NULL OR trim(vr.private_label_name)=''))
      AND NOT EXISTS (SELECT 1 FROM shopify_products sx WHERE upper(trim(sx.mfr_sku)) = upper(trim(vc.mfr_sku)))
      AND NOT EXISTS (SELECT 1 FROM dw_sku_registry rx WHERE upper(trim(rx.mfr_sku)) = upper(trim(vc.mfr_sku)))
    ORDER BY vc.id ASC
    LIMIT $2`, [vendorCode, limit]);

  let created = 0, skipped = 0, failed = 0, consecFail = 0;
  const runMfr = new Set();

  for (const r of rows) {
    const mfrNorm = String(r.mfr_sku || '').trim().toUpperCase();
    if (!mfrNorm || runMfr.has(mfrNorm)) { skipped++; continue; }
    runMfr.add(mfrNorm);

    const displayVendor = (r.is_private_label && r.private_label_name && r.private_label_name.trim())
      || PRIVATE_LABEL[vendorCode] || (r.vendor_name && r.vendor_name.trim())
      || (r.original_vendor_name && r.original_vendor_name.trim()) || titleCase(vendorCode);
    if (r.is_private_label && !((r.private_label_name && r.private_label_name.trim()) || PRIVATE_LABEL[vendorCode])) { skipped++; continue; }

    const images = parseImages(r.image_url, r.all_images);
    if (!images.length) { skipped++; continue; }
    const price = parsePrice(r.specs);
    // specs live in metafields (rendered by product-description-meta.liquid), NEVER a body list (TK-10039)
    const body = r.ai_description ? `<p>${r.ai_description}</p>` : '';

    const payload = {
      title: `${r.pattern_name}${r.color_name ? ' - ' + r.color_name : ''} | ${displayVendor}`,
      vendor: displayVendor,
      product_type: r.product_type || 'Wallcovering',
      status: allowActive ? 'active' : 'draft',
      tags: [displayVendor, r.collection].filter(Boolean).join(', '),
      body_html: body,
      variants: [
        { option1: 'Default', sku: r.dw_sku, ...(price ? { price } : {}), inventory_management: null },
        { option1: 'Sample', sku: `${r.dw_sku}-Sample`, price: SAMPLE_PRICE, inventory_management: null },
      ],
      options: [{ name: 'Title' }],
      images: images.map(src => ({ src })),
      metafields: [
        { namespace: 'custom', key: 'mfr_sku', value: String(r.mfr_sku || ''), type: 'single_line_text_field' },
        // width migrated from the old body <ul> → the metafields the theme renders (TK-10039)
        ...(r.width ? [{ namespace: 'custom', key: 'width', value: String(r.width), type: 'single_line_text_field' }, { namespace: 'global', key: 'width', value: String(r.width), type: 'single_line_text_field' }] : []),
      ],
    };
    if (/command\s*-?\s*54/i.test(JSON.stringify(payload))) { skipped++; continue; }

    const w = await shopifyCreate(store, token, payload);
    if (w.ok && w.id) {
      created++; consecFail = 0;
      let published = false, publishErr = null;
      if (allowActive) { const p = await publishOnlineStore(store, token, w.id); published = p.ok; publishErr = p.error || null; }
      // self-seal dedup + link the catalog row
      try {
        await pool.query(`UPDATE vendor_catalog SET shopify_product_id=$1, on_shopify=TRUE,
          sync_status='pushed', shopify_synced_at=now() WHERE id=$2`, [w.id, r.id]);
        await pool.query(`INSERT INTO shopify_products (shopify_id, mfr_sku, status, vendor)
          VALUES ($1,$2,$3,$4) ON CONFLICT (shopify_id) DO NOTHING`,
          [`gid://shopify/Product/${w.id}`, r.mfr_sku, allowActive ? 'ACTIVE' : 'DRAFT', displayVendor]);
      } catch (e) { log(`  warn: vc/mirror update failed for ${r.dw_sku} (product ${w.id} created): ${e.message}`); }
      log(`  pushed ${created} · ${r.dw_sku}${allowActive ? (published ? ' (published)' : ' (PUBLISH FAILED: ' + publishErr + ')') : ' (draft)'}`);
    } else {
      failed++; consecFail++;
      log(`  FAIL ${r.dw_sku}: ${w.error}`);
      if (/daily variant/i.test(w.error || '')) { log(`  Shopify daily variant limit hit — stopping ${vendorCode} push`); break; }
    }
    if (consecFail >= 10) { log(`  circuit breaker: 10 consecutive failures — abort ${vendorCode}`); break; }
    await new Promise(res => setTimeout(res, 600)); // ~1.6/s under REST 2/s
  }

  log(`  Phase 8b push: ${vendorCode} → created ${created}, skipped ${skipped}, failed ${failed}`);
  return { created, skipped, failed };
}

module.exports = { pushVendor };