← back to Atomic50 Onboard

push-shopify.js

219 lines

#!/usr/bin/env node
/**
 * Atomic 50 Ceilings — Shopify push (EXECUTE, Steve-approved 2026-07-13)
 *
 * 1. Create the 56 to-create rows (exclude LBI-BOYD resource + the 2 already-live)
 *    as DRAFT, quote-only / sample-only:
 *      - ONE variant: {DW_SKU}-Sample, $4.25, no inventory tracking, requires_shipping
 *      - NO sellable retail variant, NO product price (quote-only line)
 *      - product_type from DB (Tin Ceiling Tile / Tin Ceiling Molding / Accessory / Tool)
 *      - vendor "Atomic 50 Ceilings", DWJT- SKU, tin-ceiling metafields
 *      - image = Squarespace-CDN swatch, tags from ai_tags + type/vendor
 *    Write shopify_product_id + on_shopify=true back into atomic50_catalog per row.
 *
 * 2. Reconcile the 2 already-live products (AT50-04 pid 7799402823731,
 *    AT50-41 pid 7799395778611): product_type Wallcovering -> Tin Ceiling Tile,
 *    title -> new convention (drop banned word "Wallcovering"), align tags.
 *    Keep current status (stay active — do NOT flip to draft).
 *
 * PG-first (mirror) then Shopify (authoritative) per DW canonical write order.
 * Cost: Shopify Admin API = $0 (rate-limit throttled). $0 (local) DB writes.
 */
const { Client } = require('pg');
const fs = require('fs');

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const DRY = process.argv.includes('--dry-run');

// ---- token (parse .env directly; do NOT shell-source — unquoted-value bug) ----
function readToken() {
  const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
  for (const line of env.split('\n')) {
    const m = line.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/);
    if (m) return m[1].trim().replace(/^["']|["']$/g, '');
  }
  throw new Error('SHOPIFY_ADMIN_TOKEN not found');
}
const TOKEN = readToken();

const BAN = /wallpaper|wallcovering/i;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function shopify(method, path, body) {
  const url = `https://${SHOP}/admin/api/${API}/${path}`;
  const res = await fetch(url, {
    method,
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  const txt = await res.text();
  let json;
  try { json = txt ? JSON.parse(txt) : {}; } catch { json = { _raw: txt }; }
  // respect Shopify's leaky-bucket call limit
  const call = res.headers.get('x-shopify-shop-api-call-limit');
  if (call) {
    const [used, cap] = call.split('/').map(Number);
    if (used / cap > 0.7) await sleep(600);
  }
  return { ok: res.ok, status: res.status, json };
}

// ---- title / tags builders ----
function titleFor(row) {
  const sku = (row.mfr_sku || '').toUpperCase();       // AT50-35 (never lowercased At50-)
  const color = row.color_name || 'Unfinished';
  const t = `${sku} ${color} | Atomic 50`;
  if (BAN.test(t)) throw new Error(`banned word in title: ${t}`);
  return t;
}
function typeTagFor(pt) {
  // customer-facing tag mirror of product_type
  return pt; // "Tin Ceiling Tile" / "Tin Ceiling Molding" / "Accessory" / "Tool"
}
function tagsFor(row) {
  const set = new Set();
  set.add('Atomic 50');
  set.add('Pressed Tin');
  set.add(typeTagFor(row.product_type));
  set.add('Quote-Only');
  const at = row.ai_tags || [];
  for (const t of at) if (t && typeof t === 'string') set.add(t);
  const st = row.ai_styles || [];
  for (const t of st) if (t && typeof t === 'string') set.add(t);
  const pa = row.ai_patterns || [];
  for (const t of pa) if (t && typeof t === 'string') set.add(t);
  // strip any banned tags
  return [...set].filter((t) => !BAN.test(t)).join(', ');
}
function metafieldsFor(row) {
  // Tin-ceiling spec fields go in the `tin` namespace (no store definitions there),
  // NOT `custom` — the store already defines custom.material=multi_line and
  // custom.finish=product_reference, which reject our single-line text values.
  const mf = [];
  const push = (ns, key, value) =>
    value && mf.push({ namespace: ns, key, type: 'single_line_text_field', value: String(value) });
  push('tin', 'material', row.material);
  push('tin', 'finish', row.finish);
  push('tin', 'application', row.application);
  push('tin', 'fire_rating', row.fire_rating);
  push('tin', 'width', row.width);
  mf.push({ namespace: 'sec', key: 'mfr_sku', type: 'single_line_text_field', value: row.mfr_sku });
  mf.push({ namespace: 'sec', key: 'dw_sku', type: 'single_line_text_field', value: row.dw_sku });
  return mf;
}
function bodyFor(row) {
  const b = (row.body_html && row.body_html.trim()) || (row.ai_description && row.ai_description.trim()) || '';
  if (BAN.test(b)) throw new Error(`banned word in body for ${row.mfr_sku}`);
  return b;
}

function buildCreatePayload(row) {
  const p = {
    title: titleFor(row),
    status: 'draft',
    vendor: 'Atomic 50 Ceilings',
    product_type: row.product_type,
    tags: tagsFor(row),
    body_html: bodyFor(row),
    variants: [
      {
        option1: 'Sample',
        sku: `${row.dw_sku}-Sample`,
        price: '4.25',
        inventory_management: null,
        requires_shipping: true,
        taxable: true,
      },
    ],
    metafields: metafieldsFor(row),
  };
  if (row.image_url && /^https:\/\//.test(row.image_url)) p.images = [{ src: row.image_url }];
  return { product: p };
}

async function main() {
  const c = new Client({ host: '/tmp', database: 'dw_unified' });
  await c.connect();

  // rows to create: not LBI-BOYD, not already-live
  const { rows: toCreate } = await c.query(`
    SELECT mfr_sku, dw_sku, pattern_name, color_name, product_type, width, width_inches,
           material, finish, application, fire_rating, image_url,
           ai_tags, ai_styles, ai_patterns, body_html, ai_description
    FROM atomic50_catalog
    WHERE mfr_sku <> 'LBI-BOYD' AND shopify_product_id IS NULL
    ORDER BY product_type, mfr_sku`);

  console.log(`[create] ${toCreate.length} rows to create as DRAFT (dry-run=${DRY})`);

  const results = { created: [], failed: [], reconciled: [], reconcileFailed: [] };

  for (const row of toCreate) {
    let payload;
    try { payload = buildCreatePayload(row); }
    catch (e) { console.error(`  ! build failed ${row.mfr_sku}: ${e.message}`); results.failed.push({ mfr_sku: row.mfr_sku, error: e.message }); continue; }

    if (DRY) { console.log(`  [dry] would create ${row.mfr_sku} "${payload.product.title}" (${row.product_type})`); results.created.push({ mfr_sku: row.mfr_sku, dry: true }); continue; }

    const r = await shopify('POST', 'products.json', payload);
    if (!r.ok || !r.json.product) {
      console.error(`  ! create failed ${row.mfr_sku}: ${r.status} ${JSON.stringify(r.json.errors || r.json)}`);
      results.failed.push({ mfr_sku: row.mfr_sku, status: r.status, error: r.json.errors || r.json });
      await sleep(300);
      continue;
    }
    const pid = String(r.json.product.id);
    // PG mirror write-back (immediate local reads)
    await c.query(
      `UPDATE atomic50_catalog SET shopify_product_id=$1, on_shopify=true, updated_at=now() WHERE mfr_sku=$2`,
      [pid, row.mfr_sku]
    );
    console.log(`  + ${row.mfr_sku} -> pid ${pid} "${payload.product.title}"`);
    results.created.push({ mfr_sku: row.mfr_sku, pid, title: payload.product.title });
    await sleep(250); // pacing (well under 1k/day; leaky bucket handled above)
  }

  // ---- reconcile the 2 already-live ----
  const live = [
    { mfr_sku: 'AT50-04', pid: '7799402823731', color: 'Unfinished' },
    { mfr_sku: 'AT50-41', pid: '7799395778611', color: 'Unfinished' },
  ];
  for (const L of live) {
    const { rows } = await c.query(`SELECT * FROM atomic50_catalog WHERE mfr_sku=$1`, [L.mfr_sku]);
    const row = rows[0];
    const newTitle = titleFor(row);       // AT50-04 Unfinished | Atomic 50
    const newTags = tagsFor(row);         // tile tag set, no banned words
    const update = {
      product: {
        id: Number(L.pid),
        title: newTitle,
        product_type: 'Tin Ceiling Tile',
        tags: newTags,
        // status intentionally omitted -> keep current (active)
      },
    };
    if (DRY) { console.log(`  [dry] would reconcile ${L.mfr_sku} -> "${newTitle}" / Tin Ceiling Tile`); results.reconciled.push({ mfr_sku: L.mfr_sku, dry: true }); continue; }
    const r = await shopify('PUT', `products/${L.pid}.json`, update);
    if (!r.ok || !r.json.product) {
      console.error(`  ! reconcile failed ${L.mfr_sku}: ${r.status} ${JSON.stringify(r.json.errors || r.json)}`);
      results.reconcileFailed.push({ mfr_sku: L.mfr_sku, status: r.status, error: r.json.errors || r.json });
      continue;
    }
    await c.query(
      `UPDATE atomic50_catalog SET on_shopify=true, updated_at=now() WHERE mfr_sku=$1`,
      [L.mfr_sku]
    );
    console.log(`  ~ reconciled ${L.mfr_sku} pid ${L.pid} -> "${newTitle}" / Tin Ceiling Tile / status kept ${r.json.product.status}`);
    results.reconciled.push({ mfr_sku: L.mfr_sku, pid: L.pid, title: newTitle, status: r.json.product.status });
    await sleep(250);
  }

  await c.end();
  fs.writeFileSync('/Users/macstudio3/Projects/atomic50-onboard/data/shopify-push-result.json', JSON.stringify(results, null, 2));
  console.log(`\nDONE created=${results.created.length} failed=${results.failed.length} reconciled=${results.reconciled.length} reconcileFailed=${results.reconcileFailed.length}`);
  console.log('audit -> /Users/macstudio3/Projects/atomic50-onboard/data/shopify-push-result.json');
}

main().catch((e) => { console.error(e); process.exit(1); });