← back to Majilite Onboard

scripts/publish_shopify.js

174 lines

#!/usr/bin/env node
/**
 * Publish Majilite Metallic Specialties I as $4.25 SAMPLE products on the live
 * DW Shopify store (designer-laboratory-sandbox = production). Mirrors the DW
 * house structure: one "Sample" variant @ $4.25, dwc + custom spec metafields,
 * display_variant tag, local swatch image via staged upload.
 *
 * Idempotent: skips a SKU whose "<dw_sku>-Sample" variant already exists.
 * PG-first-then-Shopify: majilite_catalog is already staged; this creates the
 * authoritative Shopify product and records the id back to data/published.json.
 *
 * Usage:
 *   node scripts/publish_shopify.js --canary 1            # first N only
 *   node scripts/publish_shopify.js --from 2 --to 160     # range
 *   node scripts/publish_shopify.js --all                 # everything (skips done)
 *   node scripts/publish_shopify.js --status draft        # default: active
 *   node scripts/publish_shopify.js --dry                 # no writes, plan only
 */
const fs = require('fs');
const path = require('path');

const ROOT = path.resolve(__dirname, '..');
const API = '2024-10';
const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
  const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m); return m ? m[1].replace(/["']/g, '').trim() : '';
})();
const GQL = `https://${STORE}/admin/api/${API}/graphql.json`;

const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf('--' + k); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
const DRY = !!opt('dry', false);
const STATUS = (opt('status', 'active') === 'draft') ? 'DRAFT' : 'ACTIVE';
const products = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/products.json'), 'utf8'));
let range;
if (opt('canary')) range = products.slice(0, Number(opt('canary')));
else if (opt('all')) range = products;
else { const f = Number(opt('from', 1)), t = Number(opt('to', products.length)); range = products.filter(p => p.seq >= f && p.seq <= t); }

const PUB_PATH = path.join(ROOT, 'data/published.json');
const published = fs.existsSync(PUB_PATH) ? JSON.parse(fs.readFileSync(PUB_PATH, 'utf8')) : {};
const savePub = () => fs.writeFileSync(PUB_PATH, JSON.stringify(published, null, 2));
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function gql(query, variables) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
    const j = await res.json();
    if (j.errors) {
      const throttled = JSON.stringify(j.errors).includes('THROTTLED');
      if (throttled) { await sleep(1500 * (attempt + 1)); continue; }
      throw new Error('GQL errors: ' + JSON.stringify(j.errors).slice(0, 300));
    }
    return j.data;
  }
  throw new Error('GQL throttled after retries');
}

const slugify = s => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');

async function existingVariant(sku) {
  const d = await gql(`query($q:String!){ productVariants(first:1, query:$q){ nodes{ id product{ id handle status } } } }`, { q: `sku:${sku}` });
  return d.productVariants.nodes[0] || null;
}

async function uploadImage(filePath, filename) {
  const d = await gql(`mutation($input:[StagedUploadInput!]!){ stagedUploadsCreate(input:$input){ stagedTargets{ url resourceUrl parameters{ name value } } userErrors{ field message } } }`,
    { input: [{ filename, mimeType: 'image/png', resource: 'IMAGE', httpMethod: 'POST' }] });
  const err = d.stagedUploadsCreate.userErrors; if (err && err.length) throw new Error('stagedUpload: ' + JSON.stringify(err));
  const t = d.stagedUploadsCreate.stagedTargets[0];
  const form = new FormData();
  for (const p of t.parameters) form.append(p.name, p.value);
  form.append('file', new Blob([fs.readFileSync(filePath)], { type: 'image/png' }), filename);
  const up = await fetch(t.url, { method: 'POST', body: form });
  if (!(up.status >= 200 && up.status < 300)) throw new Error('staged POST ' + up.status);
  return t.resourceUrl;
}

function metafields(p) {
  const S = (ns, key, value, type = 'single_line_text_field') => ({ namespace: ns, key, type, value: String(value) });
  // Only keys proven to exist as single_line_text_field on this store (from a live
  // DWMA product). Full spec (weight/thickness/durability/FR) lives in descriptionHtml
  // to avoid colliding with typed metafield definitions (e.g. custom.weight = weight type).
  return [
    S('dwc', 'brand', p.vendor), S('dwc', 'pattern_name', p.pattern), S('dwc', 'color', p.color),
    S('dwc', 'collection', p.collection), S('dwc', 'manufacturer_sku', p.mfr_sku), S('dwc', 'width', p.spec.width),
    S('custom', 'brand', p.vendor), S('custom', 'pattern_name', p.pattern), S('custom', 'color', p.color),
    S('custom', 'collection_name', p.collection), S('custom', 'manufacturer_sku', p.mfr_sku),
    S('custom', 'width', p.spec.width), S('custom', 'color_hex', p.color_hex),
    S('custom', 'product_class', 'Wallcovering'), S('custom', 'material_category', 'Faux Leather (Nytek)'),
  ];
}

function bodyHtml(p) {
  const s = p.spec;
  return `<p><strong>${p.pattern} — ${p.color}</strong>. ${p.vendor} ${p.brand}® faux leather from the ${p.collection} collection — a specially engineered Nylon Fiber Matrix, luxurious, breathable and durable, ideal for wall coverings, seating, flat surfaces and displays.</p>`
    + `<ul><li>Width: ${s.width}</li><li>Weight: ${s.weight}</li><li>Thickness: ${s.thickness}</li>`
    + `<li>Durability: ${s.durability}</li><li>Flammability: ${s.flammability_inherent}</li>`
    + `<li>Cleanability: ${s.cleanability_code} · Crocking ${s.crocking}</li>`
    + `<li>PFAS-, PVC- and plasticizer-free · no off-gassing</li></ul>`
    + `<p><em>Available by the yard (54&quot; wide) at $${p.pricing.retail}/yd, or order a $4.25 sample swatch.</em></p>`;
}

async function publishOne(p) {
  const num = p.dw_sku.replace('DWCC-', '');
  const oldKey = `DWMJ-${num}`;             // key under which it was first published (153 of these)
  const newKey = p.dw_sku;                  // DWCC-600xxx
  const sampleSku = `${p.dw_sku}-Sample`;
  const yardSku = p.dw_sku;

  if (published[newKey] && published[newKey].reconciled) return { skip: 'done' };
  const prior = published[oldKey];          // existing live DWMJ product, if any
  const mode = (prior && prior.productId) ? 'update' : 'create';
  if (DRY) return { dry: true, mode, title: p.title };

  const baseHandle = `majilite-${slugify(p.pattern + '-' + p.color)}`;
  const tags = Array.from(new Set([...p.tags, p.dw_sku, 'display_variant', 'Sample', 'By The Yard']));
  const buildInput = (handle) => ({
    ...(mode === 'update' ? { id: prior.productId } : {}),
    handle, title: p.title, vendor: p.vendor, productType: p.product_type, status: STATUS,
    descriptionHtml: bodyHtml(p), tags,
    productOptions: [{ name: 'Type', values: [{ name: 'Sample' }, { name: 'Per Yard' }] }],
    variants: [
      { optionValues: [{ optionName: 'Type', name: 'Sample' }], price: '4.25', sku: sampleSku, inventoryPolicy: 'CONTINUE', taxable: true },
      { optionValues: [{ optionName: 'Type', name: 'Per Yard' }], price: String(p.pricing.retail), sku: yardSku, inventoryPolicy: 'CONTINUE', taxable: true },
    ],
    metafields: metafields(p),
  });
  const run = async (handle) => {
    const d = await gql(`mutation($input:ProductSetInput!){ productSet(synchronous:true, input:$input){ product{ id handle status } userErrors{ field message } } }`, { input: buildInput(handle) });
    return d.productSet;
  };

  let ps = await run(baseHandle);
  if (ps.userErrors && ps.userErrors.length) {
    const collision = JSON.stringify(ps.userErrors).toLowerCase().includes('handle');
    if (mode === 'create' && collision) ps = await run(`${baseHandle}-${num}`);   // collision-safe create
    if (ps.userErrors && ps.userErrors.length) throw new Error('productSet: ' + JSON.stringify(ps.userErrors));
  }
  const prod = ps.product;

  // image: creates get a fresh swatch; updates keep the swatch they already have
  let media = 'kept';
  if (mode === 'create') {
    media = 'none';
    try {
      const resourceUrl = await uploadImage(path.join(ROOT, 'public', p.image), `${p.dw_sku}.png`);
      const m = await gql(`mutation($id:ID!,$media:[CreateMediaInput!]!){ productCreateMedia(productId:$id, media:$media){ media{ status } mediaUserErrors{ field message } } }`,
        { id: prod.id, media: [{ originalSource: resourceUrl, alt: `${p.title} — ${p.vendor} ${p.brand}`, mediaContentType: 'IMAGE' }] });
      const me = m.productCreateMedia.mediaUserErrors; media = (me && me.length) ? 'err:' + JSON.stringify(me) : 'ok';
    } catch (e) { media = 'img-fail:' + e.message.slice(0, 80); }
  }

  published[newKey] = { productId: prod.id, handle: prod.handle, status: prod.status, sample_sku: sampleSku, yard_sku: yardSku, retail: p.pricing.retail, mode, from: (mode === 'update' ? oldKey : 'new'), image: media, reconciled: true, at: new Date().toISOString() };
  savePub();
  return { [mode === 'update' ? 'updated' : 'created']: prod.id, handle: prod.handle, image: media, mode };
}

(async () => {
  console.log(`Publish → ${STORE}  status=${STATUS}  ${DRY ? '(DRY)' : ''}  targets=${range.length}`);
  let created = 0, skipped = 0, failed = 0;
  for (const p of range) {
    try {
      const r = await publishOne(p);
      if (r.skip) { skipped++; process.stdout.write(`· ${p.dw_sku} skip(${r.skip})\n`); }
      else if (r.dry) { process.stdout.write(`plan ${p.dw_sku} [${r.mode}] ${p.title}\n`); }
      else { created++; const verb = r.updated ? '↑upd' : '✓new'; process.stdout.write(`${verb} ${p.dw_sku} ${p.title} → ${r.handle} [img:${r.image}]  (${created})\n`); }
    } catch (e) { failed++; process.stdout.write(`✗ ${p.dw_sku} ${p.title} — ${e.message.slice(0, 160)}\n`); }
    await sleep(350);
  }
  console.log(`\nDone. created=${created} skipped=${skipped} failed=${failed} | published.json has ${Object.keys(published).length} records`);
})();