← back to Sister Parish Onboarding
scripts/push_shopify.js
243 lines
#!/usr/bin/env node
/**
* Push Sister Parish prints to DW Shopify as DRAFT.
* - vendor=Sister Parish, product_type=Wallpaper, status=draft
* - compare_at_price = SP retail; price = SP retail / 0.85
* - inventory: qty=2026, tracked, policy=continue (per Steve's standing rules)
* - SKU prefix DWSP-
* - Image uploaded from src URL (Shopify pulls + rehosts)
* - Records shopify_product_id back into dw_unified.sisterparish_catalog
*/
require('dotenv').config({ path: require('path').join(__dirname,'..','.env') });
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { assertNoPublicLeak } = require(path.join(process.env.HOME, 'Projects/_shared/leak-guard/leak-guard.cjs')); // TK-12032 fail-closed private-label leak guard
const SHOP = process.env.SHOPIFY_STORE;
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
if (!SHOP || !TOKEN) { console.error('Need SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN in .env'); process.exit(1); }
const BASE = `https://${SHOP}/admin/api/2026-01`;
const RATE_DELAY_MS = 350; // ~3 req/s, well under DW Plus 4 req/s
const data = JSON.parse(fs.readFileSync(path.join(__dirname,'..','output','normalized.json'),'utf8'));
// TK-11024: width metafield (Carnegie-class). Width isn't in normalized.json; it lives in the
// per-PDP crawl (pdp_specs.json → canon.width), keyed by sp_product_id (same source that fills
// sisterparish_catalog.specs.width). Build a lookup; missing file → empty map (no-op).
let widthBySpId = new Map();
try {
const pdp = JSON.parse(fs.readFileSync(path.join(__dirname,'..','output','pdp_specs.json'),'utf8'));
for (const r of pdp) {
const w = r && r.canon && r.canon.width != null ? String(r.canon.width).trim() : '';
if (w) widthBySpId.set(String(r.sp_product_id), w);
}
} catch { /* pdp_specs.json absent → no width metafield, same as today */ }
const ARGS = new Set(process.argv.slice(2));
const DRY = ARGS.has('--dry-run');
const LIMIT = (() => {
const i = process.argv.indexOf('--limit');
return i > -1 ? parseInt(process.argv[i+1],10) : null;
})();
async function sleep(ms) { return new Promise(r=>setTimeout(r,ms)); }
async function shopify(method, pathSuffix, body) {
const url = `${BASE}${pathSuffix}`;
const res = await fetch(url, {
method,
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
if (!res.ok) {
const err = new Error(`Shopify ${method} ${pathSuffix} → ${res.status}: ${text.slice(0,400)}`);
err.status = res.status; err.body = json;
throw err;
}
return json;
}
const { safeStampQuantity } = require('./lib/inventory-stamp-guard.mjs'); // GUARD TK-11357 (shared guard)
// ── GUARD TK-11357 BEGIN ─ do not edit without re-running the fixture proof ──────────
// A $0 / quote-only sellable variant must NEVER receive positive stock: positive stock is what
// flips availableForSale=true, making it checkout-orderable at $0 (lineage TK-10825 -> 10965 ->
// 11140 -> 11299 -> 11301 -> 11357). $0 is the LIVE theme's deliberate quote-only SENTINEL
// (snippets/product-form-content.liquid renders the "Contact Us" button iff variant.price == 0),
// so the remedy is NEVER to write a placeholder price - it is "do not stock it".
// Steve's 2026-06-20 "active products are never out of stock" rule is PRESERVED for PRICED goods:
// a priced variant still gets `desired`. The quote-only tag/vendor decision is delegated to the
// shared guard (inventory-stamp-guard.mjs); this adds one strictly-safer rule of its own -
// price <= 0 / NaN is ALWAYS 0, even on a variant labelled "Sample" (a $0 "sample" is the same
// $0-orderable defect). The real $4.25 memo sample is unaffected and keeps its existing quantity.
// `inventoryItem?.id ?? null` because this writer decides on a CREATE PAYLOAD, where no
// inventoryItem id exists yet; only the .quantity is consumed there.
function safeQuantities(product, variants, locationId, desired) {
return (variants || []).map(v => ({
inventoryItemId: v.inventoryItem?.id ?? null,
locationId,
quantity: Number(v.price) > 0
? safeStampQuantity({ title: v.title, price: v.price }, product, desired)
: 0,
}));
}
// inventoryPolicy CONTINUE (oversell) makes a variant orderable at ANY quantity INCLUDING ZERO.
// So for the $0 class, "set the quantity to 0" is a NO-OP under CONTINUE and the entire
// established remedy for this defect class silently fails. A variant the guard zeroes must
// therefore ALSO be DENY, or the guard is decorative. Priced variants keep the caller's policy
// exactly as-is, so oversell behaviour for real made-to-order goods is unchanged.
function safePolicy(product, variant, desiredPolicy, desired) {
return safeQuantities(product, [variant], null, desired)[0].quantity > 0 ? desiredPolicy : 'deny';
}
// ── GUARD TK-11357 END ────────────────────────────────────────────
function buildProductPayload(p) {
// Option axes — collapse to (Pattern, Material, Size) like the source.
const allOpts = p.variants.map(v => ({ o1: v.option1, o2: v.option2, o3: v.option3 }));
const hasOpt1 = allOpts.some(v => v.o1);
const hasOpt2 = allOpts.some(v => v.o2);
const hasOpt3 = allOpts.some(v => v.o3);
const options = [];
if (hasOpt1) options.push({ name: 'Pattern' });
if (hasOpt2) options.push({ name: 'Material' });
if (hasOpt3) options.push({ name: 'Size' });
const variants = p.variants.map(v => {
const variant = {
sku: `DWSP-${v.sku || ''}`,
price: v.dw_retail.toFixed(2),
compare_at_price: v.vendor_retail.toFixed(2),
inventory_management: 'shopify',
// GUARD TK-11357: dw_retail comes straight from the input feed with no price check, so a
// 0/absent vendor_retail minted a $0 variant with policy CONTINUE — orderable at ANY
// quantity INCLUDING ZERO, which defeats the qty->0 remedy for this defect class.
inventory_policy: safePolicy({ vendor: 'Sister Parish', tags: [] }, { title: v.option3 || v.option1, price: v.dw_retail }, 'continue', 2026),
requires_shipping: true,
taxable: true,
weight: (v.grams || 0) / 1000,
weight_unit: 'kg',
};
if (hasOpt1) variant.option1 = v.option1 || null;
if (hasOpt2) variant.option2 = v.option2 || null;
if (hasOpt3) variant.option3 = v.option3 || null;
return variant;
});
const tags = [...new Set([
...(p.tags || []),
'Sister Parish',
'Wallcovering',
'Wallpaper',
'Print'
])].join(', ');
return {
product: {
title: p.title,
body_html: p.body_html || '',
vendor: 'Sister Parish',
product_type: 'Wallpaper',
status: 'draft', // standing rule: never ACTIVE without image-gate review
tags,
handle: `sp-${p.handle}`,
options: options.length ? options : undefined,
variants,
images: (p.images || []).slice(0, 10).map(i => ({ src: i.src, position: i.position, alt: i.alt || p.title })),
metafields: [
{ namespace: 'dwc', key: 'source_url', type: 'single_line_text_field', value: p.url },
{ namespace: 'dwc', key: 'source_product_id', type: 'single_line_text_field', value: String(p.sp_product_id) },
{ namespace: 'dwc', key: 'pricing_rule', type: 'single_line_text_field', value: 'vendor_retail / 0.85' },
...(widthBySpId.get(String(p.sp_product_id))
? [{ namespace: 'global', key: 'width', type: 'single_line_text_field', value: widthBySpId.get(String(p.sp_product_id)) }]
: []),
]
}
};
}
async function setVariantInventory(variantId, qty) {
// Variant inventory item → set qty at DW's default location.
// 1) Get variant → inventory_item_id
const vRes = await shopify('GET', `/variants/${variantId}.json`);
const inventoryItemId = vRes.variant.inventory_item_id;
// 2) Get default location
const locs = await shopify('GET', '/locations.json');
const locationId = locs.locations[0].id;
// 3) Connect inventory_item to location (idempotent)
try {
await shopify('POST', '/inventory_levels/connect.json', { location_id: locationId, inventory_item_id: inventoryItemId });
} catch (e) { /* already connected — ignore */ }
// 4) Set qty
// GUARD TK-11357: never stamp positive stock without a price — callers must pass the guard's
// safe quantity (a $0 variant reaching this with a positive qty is the whole defect class).
await shopify('POST', '/inventory_levels/set.json', {
location_id: locationId, inventory_item_id: inventoryItemId, available: qty
});
}
(async () => {
const products = LIMIT ? data.slice(0, LIMIT) : data;
console.log(`${DRY ? '[DRY-RUN] ' : ''}Pushing ${products.length} Sister Parish products as DRAFT…`);
const created = [];
const failed = [];
for (let i = 0; i < products.length; i++) {
const p = products[i];
const payload = buildProductPayload(p);
try { assertNoPublicLeak(payload, { context: `sister-parish ${p.sp_product_id}` }); }
catch (e) { console.error(` ⛔ LEAK GUARD ${p.sp_product_id}: ${e.message}`); failed.push({ sp_id: p.sp_product_id, error: e.message }); continue; }
const stamp = `[${String(i+1).padStart(3,'0')}/${products.length}]`;
if (DRY) {
console.log(`${stamp} would create: ${p.title} (${payload.product.variants.length} variants, ${payload.product.images.length} images)`);
continue;
}
try {
const r = await shopify('POST', '/products.json', payload);
const prod = r.product;
created.push({ sp_id: p.sp_product_id, sf_id: prod.id, handle: prod.handle, variants: prod.variants.length });
console.log(`${stamp} ✓ ${p.title} → SF #${prod.id} (${prod.variants.length} variants, ${prod.images.length} images)`);
// Set inventory qty=2026 on every PRICED variant (standing rule), per GUARD TK-11357:
// stock from the price Shopify actually landed, never a flat 2026 — a $0 variant gets 0.
for (const q of safeQuantities({ vendor: 'Sister Parish', tags: [] },
prod.variants.map(v => ({ title: v.option3 || v.option1, price: v.price, inventoryItem: { id: v.id } })), null, 2026)) {
if (q.quantity === 0) { console.log(` · ⛔ variant ${q.inventoryItemId}: $0 — NOT stocked (TK-11357 guard)`); continue; }
try {
await setVariantInventory(q.inventoryItemId, q.quantity);
await sleep(RATE_DELAY_MS);
} catch (invErr) {
console.log(` · variant ${q.inventoryItemId} inventory set failed: ${invErr.message.slice(0,140)}`);
}
}
} catch (e) {
failed.push({ sp_id: p.sp_product_id, title: p.title, err: e.message });
console.log(`${stamp} ✗ ${p.title}: ${e.message.slice(0,200)}`);
}
await sleep(RATE_DELAY_MS);
}
console.log(`\nCreated: ${created.length} Failed: ${failed.length}`);
fs.writeFileSync(path.join(__dirname,'..','output','created.json'), JSON.stringify(created, null, 2));
if (failed.length) fs.writeFileSync(path.join(__dirname,'..','output','failed.json'), JSON.stringify(failed, null, 2));
// Write back shopify_product_id into PG.
if (created.length) {
const lines = created.map(c =>
`UPDATE sisterparish_catalog SET source_url = COALESCE(source_url, '') WHERE sp_product_id=${c.sp_id};`
);
// Add shopify_product_id column if not exists, then update.
const sql = `
ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS shopify_product_id BIGINT;
ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS shopify_handle TEXT;
${created.map(c => `UPDATE sisterparish_catalog SET shopify_product_id=${c.sf_id}, shopify_handle='${c.handle.replace(/'/g,"''")}' WHERE sp_product_id=${c.sp_id};`).join('\n')}
SELECT COUNT(*) AS with_shopify FROM sisterparish_catalog WHERE shopify_product_id IS NOT NULL;
`;
fs.writeFileSync('/tmp/sp_pg_update.sql', sql);
execSync('psql dw_unified -f /tmp/sp_pg_update.sql', { stdio: 'inherit' });
}
})().catch(e => { console.error('FATAL', e); process.exit(1); });