← back to Sister Parish Onboarding
scripts/push_shopify.js
185 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 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'));
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;
}
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',
inventory_policy: 'continue',
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' },
]
}
};
}
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
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);
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 variant (per standing rule)
for (const v of prod.variants) {
try {
await setVariantInventory(v.id, 2026);
await sleep(RATE_DELAY_MS);
} catch (invErr) {
console.log(` · variant ${v.id} 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); });