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