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