← back to Designerwallcoverings
scripts/artmura-onboard/go-live-artmura.js
112 lines
#!/usr/bin/env node
/**
* Go-live for the 161 Artmura draft products. Per product:
* 1. enable inventory tracking on both variants (inventoryItemUpdate tracked:true)
* 2. activate at location + set on_hand = 2026 (inventoryActivate + inventorySetQuantities)
* 3. publish to all DW sales channels (publishablePublish, 13 publications)
* 4. set status = ACTIVE
*
* node go-live-artmura.js # dry-run summary
* node go-live-artmura.js --apply --limit=1 # canary
* node go-live-artmura.js --apply # all remaining drafts
*
* Idempotent-ish: skips products already status=active (re-runnable on failures).
*/
const fs = require('fs');
const { Pool } = require('pg');
const SHOP = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const API = '2024-10';
const EP = `https://${SHOP}/admin/api/${API}/graphql.json`;
const DB = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
const APPLY = process.argv.includes('--apply');
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
const LOCATION_ID = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd.
const TARGET_QTY = 2026;
// GMC source-fix (2026-07-08): 29646651457 = "Google & YouTube" REMOVED. New SKUs
// must NOT land on the Google channel (Shopify auto-syncs minVariantPrice = the $4.25
// sample → price_mismatch disapproval). Google is fed exclusively by the controlled
// TSV (today-viewer/build-google-feed.js). See scripts/_lib/dw-publications.js.
// Rollback: re-add 29646651457 below.
const PUBLICATIONS = [
22208643184, 22497296496, /* 29646651457 Google-REMOVED */ 29739483201, 29776969793, 37904089153,
43657658419, 44234276915, 44317474867, 44317507635, 71898464307, 115856375859, 140027723827,
].map(n => `gid://shopify/Publication/${n}`);
if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
const pool = new Pool({ connectionString: DB });
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function gql(query, variables = {}) {
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch(EP, { method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }) });
const json = await res.json();
if (json.errors) { if (attempt === 3) throw new Error(JSON.stringify(json.errors)); await sleep(900 * (attempt + 1)); continue; }
const rem = json.extensions?.cost?.throttleStatus?.currentlyAvailable;
if (rem !== undefined && rem < 250) await sleep(600);
return json.data;
} catch (e) { if (attempt === 3) throw e; await sleep(900 * (attempt + 1)); }
}
}
const Q_VARIANTS = `query($id:ID!){ product(id:$id){ status variants(first:10){edges{node{id sku inventoryItem{id}}}}}}`;
const M_TRACK = `mutation($id:ID!){ inventoryItemUpdate(id:$id, input:{tracked:true}){ userErrors{message} } }`;
const M_ACTIVATE = `mutation($iid:ID!,$loc:ID!){ inventoryActivate(inventoryItemId:$iid, locationId:$loc){ userErrors{message} } }`;
const M_SETQTY = `mutation($input:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$input){ userErrors{field message code} } }`;
const M_PUBLISH = `mutation($id:ID!,$pubs:[PublicationInput!]!){ publishablePublish(id:$id, input:$pubs){ userErrors{message} } }`;
const M_ACTIVE = `mutation($id:ID!){ productUpdate(input:{id:$id, status:ACTIVE}){ product{status} userErrors{message} } }`;
async function goLive(pid) {
const gidP = `gid://shopify/Product/${pid}`;
const errs = [];
const d = await gql(Q_VARIANTS, { id: gidP });
if (d.product?.status === 'ACTIVE') return { skipped: true };
const items = d.product.variants.edges.map(e => e.node.inventoryItem.id);
// 1+2: track, activate, set 2026 per item
for (const iid of items) {
let r = await gql(M_TRACK, { id: iid }); (r.inventoryItemUpdate?.userErrors || []).forEach(e => errs.push('track:' + e.message));
r = await gql(M_ACTIVATE, { iid, loc: LOCATION_ID });
const ae = (r.inventoryActivate?.userErrors || []).filter(e => !/already/i.test(e.message));
ae.forEach(e => errs.push('activate:' + e.message));
}
const r2 = await gql(M_SETQTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true,
quantities: items.map(iid => ({ inventoryItemId: iid, locationId: LOCATION_ID, quantity: TARGET_QTY })) } });
(r2.inventorySetQuantities?.userErrors || []).forEach(e => errs.push('setqty:' + e.message));
// 3: publish to all channels
const r3 = await gql(M_PUBLISH, { id: gidP, pubs: PUBLICATIONS.map(p => ({ publicationId: p })) });
(r3.publishablePublish?.userErrors || []).forEach(e => errs.push('publish:' + e.message));
// 4: activate product
const r4 = await gql(M_ACTIVE, { id: gidP });
(r4.productUpdate?.userErrors || []).forEach(e => errs.push('active:' + e.message));
return { status: r4.productUpdate?.product?.status, errs };
}
(async () => {
const { rows } = await pool.query(
`SELECT handle, dw_sku, shopify_product_id FROM newwall_catalog WHERE vendor_name='Artmura' AND shopify_product_id IS NOT NULL AND shopify_product_id<>'' ORDER BY dw_sku`);
let todo = rows;
if (LIMIT > 0) todo = todo.slice(0, LIMIT);
console.log(`go-live: ${todo.length} products · location ${LOCATION_ID} · qty ${TARGET_QTY} · channels ${PUBLICATIONS.length} · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
if (!APPLY) { console.log('first 3:', todo.slice(0, 3).map(r => r.dw_sku).join(', ')); console.log('DRY-RUN. --apply to execute.'); await pool.end(); return; }
let ok = 0, skip = 0, withErr = 0;
for (const r of todo) {
try {
const res = await goLive(r.shopify_product_id);
if (res.skipped) { skip++; }
else if (res.errs && res.errs.length) { withErr++; console.error(` ⚠ ${r.dw_sku} ${res.status||'?'}: ${res.errs.slice(0,3).join(' | ')}`); }
else { ok++; }
if (!res.skipped && res.status === 'ACTIVE') await pool.query(`UPDATE newwall_catalog SET on_shopify=true, updated_at=now() WHERE handle=$1`, [r.handle]);
if ((ok + skip + withErr) % 20 === 0) console.log(` ...${ok + skip + withErr}/${todo.length} (active=${ok} skip=${skip} err=${withErr})`);
await sleep(350);
} catch (e) { withErr++; console.error(` ERR ${r.dw_sku}: ${e.message.slice(0, 160)}`); await sleep(600); }
}
console.log(`\nDONE. active=${ok} skipped=${skip} with-errors=${withErr} of ${todo.length}`);
await pool.end();
})().catch(e => { console.error(e); process.exit(1); });