← 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
A phase8-fix/phase8-create-publish.patchA phase8-fix/shopify-push.js
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 →