[object Object]

← back to Atomic50 Onboard

atomic50: Shopify push script — 56 DRAFT quote-only creates + reconcile 2 live tiles (PG-first)

547b48322c4e29523bd9cb9e0e6d35e866d61474 · 2026-07-13 15:25:42 -0700 · Steve

Files touched

Diff

commit 547b48322c4e29523bd9cb9e0e6d35e866d61474
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 13 15:25:42 2026 -0700

    atomic50: Shopify push script — 56 DRAFT quote-only creates + reconcile 2 live tiles (PG-first)
---
 push-shopify.js | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 215 insertions(+)

diff --git a/push-shopify.js b/push-shopify.js
new file mode 100644
index 0000000..bc202dd
--- /dev/null
+++ b/push-shopify.js
@@ -0,0 +1,215 @@
+#!/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) {
+  const mf = [];
+  const push = (key, value) =>
+    value && mf.push({ namespace: 'custom', key, type: 'single_line_text_field', value: String(value) });
+  push('material', row.material);
+  push('finish', row.finish);
+  push('application', row.application);
+  push('fire_rating', row.fire_rating);
+  if (row.width) push('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); });

← 611fa54 pin PORT to 9964 (9942 taken by atomic50-viewer internal too  ·  back to Atomic50 Onboard  ·  auto-save: 2026-07-13T15:25:47 (1 files) — data/shopify-push 142d5be →