[object Object]

← back to New Import Viewer

phase8-fix: real create+publish module (mirrors consumer.js 96cd954 publish-gap fix) + wiring patch for Kamatera full-monte

b4ec23e9bbdcef577911baff5e0d19d13227434b · 2026-06-19 09:32:57 -0700 · SteveStudio2

Files touched

Diff

commit b4ec23e9bbdcef577911baff5e0d19d13227434b
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Fri Jun 19 09:32:57 2026 -0700

    phase8-fix: real create+publish module (mirrors consumer.js 96cd954 publish-gap fix) + wiring patch for Kamatera full-monte
---
 phase8-fix/phase8-create-publish.patch | 104 +++++++++++++++++
 phase8-fix/shopify-push.js             | 199 +++++++++++++++++++++++++++++++++
 2 files changed, 303 insertions(+)

diff --git a/phase8-fix/phase8-create-publish.patch b/phase8-fix/phase8-create-publish.patch
new file mode 100644
index 0000000..07a0e56
--- /dev/null
+++ b/phase8-fix/phase8-create-publish.patch
@@ -0,0 +1,104 @@
+# Phase 8 REAL create+publish fix for full-monte
+# Target: /root/DW-Agents/full-monte/full-monte-batch.js (Kamatera)
+# Companion module: scp shopify-push.js → /root/DW-Agents/full-monte/shopify-push.js
+#
+# WHAT THIS FIXES
+# Phase 8 today runs ONLY sub-phase 8a (metafield migration on EXISTING products)
+# then stamps phase8_shopify_at=NOW() as if it pushed — it creates ZERO products.
+# This adds sub-phase 8b: a real create+publish step that reuses the canonical,
+# publish-gap-fixed pattern (publishablePublish → Online Store) from Mac2
+# new-import-viewer/consumer.js (commit 96cd954).
+#
+# SAFETY
+#  • Default DRAFT (no publish). Real ACTIVE+publish requires BOTH
+#    PHASE8_PUSH_LIVE=1 (env ceiling) AND --allow-active (the Steve gate).
+#  • Same THREE-SOURCE NEVER-DUPLICATE + image + sample-variant gates as consumer.js.
+#  • token comes from full-monte/.env (already chmod-600 there); the cron line must
+#    source it (it already does for 8a's token use).
+#
+# ── PATCH 1 of 4: load .env + require the push module (top of file, after `const tracking...`) ──
+# FIND (line ~16):
+const tracking = require('./tracking');
+# REPLACE WITH:
+const tracking = require('./tracking');
+require('dotenv').config({ path: require('path').join(__dirname, '.env') }); // load SHOPIFY_ADMIN_TOKEN
+const { pushVendor } = require('./shopify-push');                            // Phase 8b real push
+
+# ── PATCH 2 of 4: add the --allow-active flag (in the arg switch, next to --full-monte ~line 149) ──
+# FIND:
+      case '--full-monte':
+        opts.fullMonte = true;
+        break;
+# REPLACE WITH:
+      case '--full-monte':
+        opts.fullMonte = true;
+        break;
+      case '--allow-active':
+        opts.allowActive = true;   // Phase 8b: create ACTIVE + publish (else DRAFT)
+        break;
+
+# ── PATCH 3 of 4: thread allowActive into runPhase (find the runPhase signature ~line 547) ──
+# FIND:
+async function runPhase(vendorCode, catalogTable, phase, limit) {
+# REPLACE WITH:
+async function runPhase(vendorCode, catalogTable, phase, limit, allowActive = false) {
+#
+# AND update the two call sites that invoke runPhase to pass opts.allowActive:
+#   • in runFullMonte's loop:   await runPhase(vendorCode, catalogTable, phase, limit, allowActive)
+#   • in the single-phase path: await runPhase(opts.vendor..., opts.phase, opts.limit, opts.allowActive)
+# (grep -n 'runPhase(' full-monte-batch.js to find both; add the trailing arg.)
+
+# ── PATCH 4 of 4: add sub-phase 8b inside `case 8:` (after the 8a try/catch, BEFORE the tracking UPDATE) ──
+# FIND (the tracking update that currently ends case 8, ~line 697):
+      // Update tracking
+      await tracking.pool.query(`
+        UPDATE enrichment_tracking
+        SET phase8_shopify_at = NOW()
+        WHERE vendor_code = $1 AND phase8_shopify_at IS NULL
+      `, [vendorCode]);
+
+      break;
+    }
+# REPLACE WITH:
+      // Sub-phase 8b: REAL create + publish (the actual Shopify push).
+      // DRAFT unless BOTH PHASE8_PUSH_LIVE=1 and --allow-active are present.
+      const PUSH_LIVE = process.env.PHASE8_PUSH_LIVE === '1';
+      if (PUSH_LIVE) {
+        try {
+          const token = process.env.SHOPIFY_ADMIN_TOKEN;
+          const store = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+          console.log(`  Phase 8b: create+publish for ${vendorCode} (allowActive=${!!allowActive})`);
+          const res = await pushVendor(vendorCode, {
+            pool: tracking.pool, token, store,
+            limit: limit || 400, allowActive: !!allowActive, log: (m) => { console.log(m); log(m); },
+          });
+          log(`Phase 8b push ${vendorCode}: created ${res.created}, skipped ${res.skipped}, failed ${res.failed}`);
+        } catch (err) {
+          console.error(`  Phase 8b push error: ${err.message}`);
+          log(`Phase 8b error: ${err.message}`);
+        }
+      } else {
+        console.log(`  Phase 8b skipped (PHASE8_PUSH_LIVE != 1) — metafield-only run`);
+      }
+
+      // Update tracking (only after the push ran, real or skipped)
+      await tracking.pool.query(`
+        UPDATE enrichment_tracking
+        SET phase8_shopify_at = NOW()
+        WHERE vendor_code = $1 AND phase8_shopify_at IS NULL
+      `, [vendorCode]);
+
+      break;
+    }
+
+# ── INSTALL ──
+# 1. scp shopify-push.js → /root/DW-Agents/full-monte/shopify-push.js
+# 2. apply patches 1-4 above to /root/DW-Agents/full-monte/full-monte-batch.js
+# 3. ensure dotenv is installed there:  (cd /root/DW-Agents/full-monte && npm ls dotenv || npm i dotenv)
+# 4. add SHOPIFY_STORE=designer-laboratory-sandbox.myshopify.com to full-monte/.env (optional; default is hardcoded)
+# 5. dry test (DRAFT, no publish):
+#      cd /root/DW-Agents/full-monte && PHASE8_PUSH_LIVE=1 node full-monte-batch.js --vendor marburg --phase 8 --limit 5
+#      (creates 5 DRAFTS — verify in Shopify admin, then archive the test 5)
+# 6. live (Steve-gated): add --allow-active and keep --limit small until the daily
+#    variant window is confirmed clear:
+#      PHASE8_PUSH_LIVE=1 node full-monte-batch.js --vendor marburg --phase 8 --allow-active --limit 42
diff --git a/phase8-fix/shopify-push.js b/phase8-fix/shopify-push.js
new file mode 100644
index 0000000..5ece0a8
--- /dev/null
+++ b/phase8-fix/shopify-push.js
@@ -0,0 +1,199 @@
+#!/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);
+    const body = (r.ai_description ? `<p>${r.ai_description}</p>` : '') +
+      (r.width ? `<ul><li><strong>Width:</strong> ${r.width}</li></ul>` : '');
+
+    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' }],
+    };
+    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 };

← c345dc3 cadence: hourly wrapper (run-cadence.sh, --limit 42 live+pub  ·  back to New Import Viewer  ·  Add one-shot auto first-run (429-gated validated 42-batch + 4573e7c →