← back to All Designerwallcoverings
scripts/shopify-push.mjs
194 lines
#!/usr/bin/env node
// FileMaker → Shopify spec push worker (per-SKU). DRY-RUN by default.
// ─────────────────────────────────────────────────────────────────────────────
// Steve: "Create button to update shopify with name, mfr, width, all specs" — the
// data flowing FROM FileMaker's WALLPAPER master INTO the live Shopify product. This
// worker owns one SKU: read the FileMaker mirror row + the live Shopify product, compute
// the diff, and (only when explicitly gated) write the non-empty, changed fields.
//
// Spawned by all-designerwallcoverings/server.js (the /api/shopify-push endpoint), same
// pattern as scripts/live-scrape.js. Emits ONE JSON object on stdout.
//
// HARD SAFETY RAILS:
// • NEVER writes an empty FileMaker value (a blank FM cell must not blank live Shopify data).
// • NEVER touches custom.material (the double-encoding-bug field) or any color_* / color_details
// metafield (owned by the local PIL color-enrichment pipeline).
// • Only writes metafield keys that ALREADY EXIST on the product, except the core mfr + width
// keys which are allowed to be created — so the push can't sprawl new metafields.
// • --commit is a NO-OP unless SHOPIFY_PUSH_ENABLED=1 is ALSO set in the environment. The
// dry-run diff is always free and side-effect-free. Live writes stay Steve-gated.
//
// Usage:
// node scripts/shopify-push.mjs <dw_sku> # dry-run: prints the diff, writes nothing
// node scripts/shopify-push.mjs <dw_sku> --commit # write (requires SHOPIFY_PUSH_ENABLED=1)
import pg from 'pg';
import fs from 'node:fs';
import { pathToFileURL } from 'node:url';
// Portable resolve of the shared Shopify Admin client — env override first, then the mac3
// designerwallcoverings repo, then a co-located copy if one is vendored. Keeps the worker from
// hard-crashing on a missing absolute path when it runs somewhere other than mac3.
const SHOPIFY_LIB = [
process.env.SHOPIFY_LIB,
'/Users/macstudio3/Projects/designerwallcoverings/scripts/lib/shopify.mjs',
new URL('./lib/shopify.mjs', import.meta.url).pathname,
].find((p) => p && fs.existsSync(p));
const gql = SHOPIFY_LIB
? (await import(pathToFileURL(SHOPIFY_LIB).href)).gql
: () => { throw new Error('Shopify client not found (set SHOPIFY_LIB or install the shared lib)'); };
const DSN = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
const dwSkuArg = process.argv[2];
const COMMIT = process.argv.includes('--commit');
const PUSH_ENABLED = process.env.SHOPIFY_PUSH_ENABLED === '1';
const out = (o) => { process.stdout.write(JSON.stringify(o)); process.exit(o.ok === false ? 1 : 0); };
if (!dwSkuArg) out({ ok: false, reason: 'dw_sku required' });
// ── FileMaker-field → Shopify-metafield target map ───────────────────────────────
// Each FM value fans out to the DW metafield keys that mirror it (discovered from a real
// product). `create:true` = allowed to be created if absent (the core identity fields);
// everything else is written ONLY if the key already exists on the product (no sprawl).
// Deliberately EXCLUDED: custom.material (double-encode bug), custom.color* / color_details
// (local color pipeline owns those).
const T = (namespace, key, create = false) => ({ namespace, key, create });
const FIELD_TARGETS = {
// key = FileMaker mirror field OR specs.<key>; value = metafield targets
mfr_number: [T('custom', 'manufacturer_sku', true), T('dwc', 'manufacturer_sku', true)],
width: [T('specifications', 'width', true), T('global', 'width', true), T('dwc', 'width'), T('custom', 'width')],
content: [T('global', 'Contents'), T('dwc', 'contents'), T('specifications', 'material'), T('global', 'material')],
collection: [T('global', 'Collection'), T('custom', 'collection_name')],
length: [T('global', 'length'), T('custom', 'length'), T('specifications', 'roll_length')],
brand: [T('global', 'Brand'), T('custom', 'brand')],
type: [T('global', 'type')],
style: [T('specs', 'style')],
sold_per: [T('global', 'sold_by')],
product_category: [T('specs', 'pattern')],
};
async function main() {
const pool = new pg.Pool({ connectionString: DSN, max: 2 });
// FileMaker's comboskuwithdash is bare (no -SAMPLE); a card may pass the Shopify variant sku
// WITH the suffix. Normalize once so BOTH lookups key on the same bare dw_sku.
const dwBare = dwSkuArg.replace(/-sample$/i, '');
// 1. FileMaker source of truth
const { rows: fmRows } = await pool.query(
`SELECT * FROM filemaker_wallpaper WHERE upper(dw_sku)=upper($1) LIMIT 1`, [dwBare]);
const fm = fmRows[0];
if (!fm) { await pool.end(); return out({ ok: false, dw_sku: dwSkuArg, reason: 'not in FileMaker WALLPAPER file' }); }
if (fm.is_discontinued) { await pool.end(); return out({ ok: false, dw_sku: dwSkuArg, reason: 'FileMaker marks this DISCONTINUED — refusing to push' }); }
// 2. the live Shopify product (GID via the mirror). The SKU may live in dw_sku, variant_sku,
// OR sku (dw_sku is often null on the sample-variant rows) — match any, -SAMPLE-stripped.
const { rows: spRows } = await pool.query(
`SELECT shopify_id, handle, title, status FROM shopify_products
WHERE upper($1) IN (
upper(regexp_replace(coalesce(dw_sku,''),'-SAMPLE$','','i')),
upper(regexp_replace(coalesce(variant_sku,''),'-SAMPLE$','','i')),
upper(regexp_replace(coalesce(sku,''),'-SAMPLE$','','i')) )
AND shopify_id IS NOT NULL
ORDER BY (upper(status)='ACTIVE') DESC LIMIT 1`, [dwBare]);
await pool.end();
const sp = spRows[0];
if (!sp) return out({ ok: false, dw_sku: dwSkuArg, reason: 'no Shopify product for this SKU (FileMaker-only — would need CREATE, not update)', fm_only: true });
const gid = /^gid:/.test(sp.shopify_id) ? sp.shopify_id : `gid://shopify/Product/${String(sp.shopify_id).match(/\d+$/)?.[0]}`;
// 3. read the product's current title + existing metafields
const pd = await gql(`{ product(id:${JSON.stringify(gid)}) { id title status
metafields(first:80){ nodes { namespace key type value } } } }`);
if (pd?.__err || !pd?.product) return out({ ok: false, dw_sku: dwSkuArg, reason: 'Shopify product read failed', detail: pd?.__err });
const prod = pd.product;
const have = new Map(); // "ns.key" → {type,value}
for (const m of prod.metafields.nodes) have.set(`${m.namespace}.${m.key}`, { type: m.type, value: m.value });
// 4. build the FileMaker source values (non-empty only)
const specs = fm.specs || {};
const srcVal = (fieldKey) => {
if (fieldKey === 'mfr_number') return (fm.mfr_number || '').trim();
if (fieldKey === 'width') return (fm.width || specs.width || '').trim();
return (specs[fieldKey] || '').trim();
};
// Don't-downgrade guard: if Shopify's current value already CONTAINS the FileMaker value
// (normalized to alphanumerics), Shopify is a richer superset ("36\" (91CM)" ⊇ "36\"",
// "Linen Gesso - Ivory … | Schumacher" ⊇ "Linen Gesso") — pushing would strip detail, so we
// record it as an info-only skip rather than a proposed change. FileMaker is authoritative
// for the mfr NUMBER (identity), not for how richly a spec/name is merchandised.
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const shopifyRicher = (before, val) => { const b = norm(before), v = norm(val); return !!b && !!v && b.includes(v) && b !== v; };
// 5. diff
const changes = []; // proposed metafield changes
const skipped = []; // info-only: differs but Shopify is richer
const metafieldsToSet = [];
for (const [fieldKey, targets] of Object.entries(FIELD_TARGETS)) {
const val = srcVal(fieldKey);
if (!val) continue; // RULE: never push an empty FM value
// Guard: an mfr NUMBER never contains whitespace. When FileMaker's mfr value looks like a
// name (e.g. "Cabochon Verdigris") but Shopify already has a compact code, don't propose
// overwriting — surface it for human verification instead. FMPro is authoritative for the
// mfr, but only when it actually holds a number.
if (fieldKey === 'mfr_number' && /\s/.test(val)) { skipped.push({ field: fieldKey, to: val, reason: 'FileMaker mfr looks like a name, not a number — verify in FileMaker' }); break; }
for (const t of targets) {
const id = `${t.namespace}.${t.key}`;
const cur = have.get(id);
if (!cur && !t.create) continue; // no sprawl: skip keys not already present
const before = cur ? (cur.value || '') : null;
if (String(before ?? '') === val) continue; // already matches
if (shopifyRicher(before, val)) { skipped.push({ field: fieldKey, metafield: id, from: before, to: val, reason: 'shopify value is richer' }); continue; }
changes.push({ field: fieldKey, metafield: id, from: before, to: val });
metafieldsToSet.push({ ownerId: gid, namespace: t.namespace, key: t.key,
type: cur?.type || 'single_line_text_field', value: val });
}
}
// title (name) — only when FileMaker has a name, it differs, AND Shopify isn't already richer
const fmName = (fm.name_of_pattern || fm.title || '').trim();
let titleChange = null;
if (fmName && fmName !== (prod.title || '').trim()) {
if (shopifyRicher(prod.title, fmName)) skipped.push({ field: 'title', from: prod.title, to: fmName, reason: 'shopify title is richer' });
else titleChange = { from: prod.title, to: fmName };
}
const plan = {
ok: true, dw_sku: fm.dw_sku, product_id: gid, handle: sp.handle,
shopify_title: prod.title, fm_name: fmName || null,
fm_mfr_number: fm.mfr_number || null,
title_change: titleChange,
metafield_changes: changes,
skipped_richer: skipped,
change_count: changes.length + (titleChange ? 1 : 0),
committed: false,
};
if (!COMMIT || plan.change_count === 0) {
plan.note = plan.change_count === 0 ? 'No changes — Shopify already matches FileMaker.'
: 'DRY RUN — call with --commit (and SHOPIFY_PUSH_ENABLED=1) to write.';
return out(plan);
}
// ── COMMIT (gated) ──────────────────────────────────────────────────────────────
if (!PUSH_ENABLED) {
plan.note = 'COMMIT BLOCKED: SHOPIFY_PUSH_ENABLED is not set. This is the Steve-gated live-write switch.';
plan.blocked = true;
return out(plan);
}
const errors = [];
if (titleChange) {
const r = await gql(`mutation($input:ProductInput!){ productUpdate(input:$input){ userErrors{ field message } } }`,
{ input: { id: gid, title: fmName } });
if (r?.__err || r?.productUpdate?.userErrors?.length) errors.push({ title: r?.productUpdate?.userErrors || r?.__err });
}
for (let i = 0; i < metafieldsToSet.length; i += 25) {
const batch = metafieldsToSet.slice(i, i + 25);
const r = await gql(`mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ userErrors{ field message } } }`,
{ m: batch });
if (r?.__err || r?.metafieldsSet?.userErrors?.length) errors.push({ metafields: r?.metafieldsSet?.userErrors || r?.__err });
}
plan.committed = errors.length === 0;
plan.errors = errors;
plan.note = errors.length ? 'Committed with errors — see errors[].' : 'Committed to live Shopify.';
return out(plan);
}
main().catch((e) => out({ ok: false, dw_sku: dwSkuArg, reason: 'push worker error: ' + e.message }));