← back to Letsbegin

enrich-write.js

338 lines

#!/usr/bin/env node
/**
 * enrich-write.js — WRITE-ONLY Claude-vision enrichment helper.
 *
 * Vision is done by the CALLING AGENT (Claude looks at the product image and
 * derives color + tags). This script does NO vision — it just writes the
 * agent's findings to Shopify metafields + image alt tags + normalizes the
 * title + stamps the dw_unified.shopify_products row. Idempotent (safe re-run).
 *
 * Why: Gemini is spend-capped, so Claude/agents are the vision analyzer instead
 * of last20day_full_monty.js's inline Gemini call. This is the write half of
 * that script, with the Gemini phase ripped out.
 *
 * Usage:
 *   node enrich-write.js <shopify_id> <json-payload-file>
 *
 * <shopify_id> may be a bare numeric id or a full gid://shopify/Product/<id>.
 *
 * payload JSON:
 *   {
 *     "colorName":    "Olive Green",            // required — dominant color name
 *     "hex":          "#6B7A3A",                // required — dominant hex
 *     "colorDetails": [ {"name":"Olive Green","hex":"#6B7A3A","percentage":55}, ... ]
 *                     // or { "colors":[...], "background":"White", ... } — stored as-is as JSON
 *     "specs":        { "material":"Non-woven", "finish":"Matte", ... },  // optional → custom.<key>
 *     "styleTags":    ["botanical","mural","dark"],   // optional — added as Shopify product tags
 *     "title":        "Palm Jungle - Olive Green | Cole & Son"  // null/absent → auto-normalize from current title
 *   }
 *
 * Env: SHOPIFY_ADMIN_TOKEN (or SHOPIFY_PRODUCT_TOKEN). PG* optional (defaults
 *      user stevestudio2, db dw_unified, local socket).
 *
 * Exit non-zero on any Shopify userError or fatal.
 */

const https = require('https');
const fs = require('fs');
const { Client } = require('pg');

const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_PRODUCT_TOKEN;
const API = '/admin/api/2024-10/graphql.json';
if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN (or SHOPIFY_PRODUCT_TOKEN) env var required'); process.exit(1); }

// HARD GUARD: sandbox store only (matches rw-cost-write.js / rw-material-options.js).
if (STORE !== 'designer-laboratory-sandbox.myshopify.com') {
  console.error(`FATAL: refusing to write to non-sandbox store "${STORE}"`); process.exit(1);
}

// ---------- args ----------
const rawId = process.argv[2];
const payloadFile = process.argv[3];
if (!rawId || !payloadFile) {
  console.error('Usage: node enrich-write.js <shopify_id> <json-payload-file>');
  process.exit(2);
}
const PID = /^gid:\/\//.test(rawId) ? rawId : `gid://shopify/Product/${String(rawId).replace(/\D/g, '')}`;

let payload;
try { payload = JSON.parse(fs.readFileSync(payloadFile, 'utf8')); }
catch (e) { console.error(`FATAL: cannot read/parse payload file: ${e.message}`); process.exit(2); }

const colorName = (payload.colorName || '').trim();
const hex = (payload.hex || '').trim();
if (!colorName || !hex) { console.error('FATAL: payload requires colorName + hex'); process.exit(2); }
const colorDetails = payload.colorDetails != null ? payload.colorDetails : null;
const specs = payload.specs && typeof payload.specs === 'object' ? payload.specs : {};
const styleTags = Array.isArray(payload.styleTags) ? payload.styleTags.map(t => String(t).trim()).filter(Boolean) : [];
const providedTitle = payload.title != null && String(payload.title).trim() ? String(payload.title).trim() : null;

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

// ---------- Shopify GraphQL (from last20day_full_monty.js) ----------
function gqlRaw(body) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = https.request({
      hostname: STORE, path: API, method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } }); });
    req.on('error', reject);
    req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
    req.write(data); req.end();
  });
}
async function gql(body, retries = 4) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const result = await gqlRaw(body);
      const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
      const lowBudget = (result?.extensions?.cost?.throttleStatus?.currentlyAvailable || 9999) < 200;
      if (throttled) { await sleep(3000); continue; }
      if (lowBudget) await sleep(1500);
      return result;
    } catch (e) {
      if (attempt < retries) { await sleep(attempt * 2000); continue; }
      throw e;
    }
  }
}

// ---------- title helpers (verbatim from last20day_full_monty.js) ----------
function stripVendorSuffix(title) {
  const pi = title.indexOf('|');
  return (pi >= 0 ? title.slice(0, pi) : title).trim();
}
function extractPatternAndColor(title) {
  let cleaned = stripVendorSuffix(title);
  cleaned = cleaned.replace(/\(tm\)/gi, '').replace(/\bwallcoverings?\b/gi, '').replace(/\bwall\s*coverings?\b/gi, '').replace(/\bwall\s*papers?\b/gi, '').replace(/\s+/g, ' ').trim();
  const commaIdx = cleaned.search(/,\s/);
  const dashIdx = cleaned.search(/\s[-–]\s/);
  function lastSep(re) { let m, last = -1; const r = new RegExp(re, 'g'); while ((m = r.exec(cleaned))) last = m.index; return last; }
  const lastComma = lastSep(',\\s');
  const lastDash = lastSep('\\s[-–]\\s');
  if (commaIdx === -1 && dashIdx === -1) return { pattern: cleaned.trim(), color: null };
  if (lastDash >= 0 && lastDash >= lastComma) {
    const m = cleaned.slice(0, lastDash).trim();
    const c = cleaned.slice(lastDash).replace(/^\s[-–]\s/, '').trim();
    return { pattern: m, color: c };
  }
  if (lastComma >= 0) {
    const m = cleaned.slice(0, lastComma).trim();
    const c = cleaned.slice(lastComma + 1).trim();
    return { pattern: m, color: c };
  }
  return { pattern: cleaned.trim(), color: null };
}
function toTitleCase(s) { if (!s) return ''; return s.replace(/\b\w+/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()); }
function buildTitle(pattern, color, vendor) {
  const tc = toTitleCase(color);
  return tc ? `${pattern} - ${tc} | ${vendor}` : `${pattern} | ${vendor}`;
}

// ---------- spec key/type map (same conventions as last20day FIELD_MAP) ----------
const MULTILINE_KEYS = new Set(['material', 'description', 'care_instructions']);
function specType(key) { return MULTILINE_KEYS.has(key) ? 'multi_line_text_field' : 'single_line_text_field'; }

// Look up the existing PRODUCT metafield definition type for custom.<key>.
// Returns the type name (e.g. 'product_reference', 'single_line_text_field')
// or null if no definition exists (free text accepted). Cached per process.
const _defCache = new Map();
async function definitionType(key) {
  if (_defCache.has(key)) return _defCache.get(key);
  let t = null;
  try {
    const r = await gql({ query: `{ metafieldDefinitions(first:1, ownerType: PRODUCT, namespace:"custom", key:"${key}"){ edges { node { type { name } } } } }` });
    const edges = r?.data?.metafieldDefinitions?.edges || [];
    if (edges.length) t = edges[0].node.type.name;
  } catch { t = null; }
  _defCache.set(key, t);
  return t;
}

const PRODUCT_QUERY = (id) => ({ query: `{
  product(id:"${id}") {
    id title vendor tags
    images(first:15){ edges{ node{ id url } } }
    media(first:15){ edges{ node{ ... on MediaImage { id } } } }
    variants(first:5){ edges{ node{ id sku } } }
    metafields(first:100){ edges{ node{ namespace key value type } } }
  }
}` });

const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
const PRODUCT_UPDATE = 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message field } } }';

async function main() {
  const errors = [];

  // ---- fetch product ----
  const fr = await gql(PRODUCT_QUERY(PID));
  const p = fr?.data?.product;
  if (!p) { console.error(`FATAL: product ${PID} not found on Shopify (resp: ${JSON.stringify(fr).slice(0, 200)})`); process.exit(1); }
  const origTitle = p.title || '';
  const vendor = p.vendor || 'Designer Wallcoverings';
  const firstVariant = p.variants?.edges?.find(e => e.node?.sku && !/-sample$/i.test(e.node.sku))?.node || p.variants?.edges?.[0]?.node;
  const sku = (firstVariant?.sku || '').replace(/-sample$/i, '').trim();
  const existingTags = Array.isArray(p.tags) ? p.tags : [];

  console.log(`[enrich-write] ${PID}`);
  console.log(`  vendor=${vendor} sku=${sku || '(none)'}`);
  console.log(`  origTitle="${origTitle}"`);

  // ---- 1a. COLOR metafields (always written in their own batch so a bad spec
  //         key can't poison the color write — see pilot note re: legacy
  //         product_reference definitions on this sandbox store) ----
  const colorMf = [
    { ownerId: PID, namespace: 'custom', key: 'dominant_color', value: colorName, type: 'single_line_text_field' },
    { ownerId: PID, namespace: 'custom', key: 'dominant_hex', value: hex, type: 'single_line_text_field' },
    // mirror to the keys last20day_full_monty.js wrote so existing readers stay happy
    { ownerId: PID, namespace: 'custom', key: 'color_name', value: colorName, type: 'single_line_text_field' },
    { ownerId: PID, namespace: 'custom', key: 'color_hex', value: hex, type: 'single_line_text_field' },
  ];
  if (colorDetails != null) {
    colorMf.push({ ownerId: PID, namespace: 'custom', key: 'color_details', value: JSON.stringify(colorDetails), type: 'json' });
  }
  {
    const r = await gql({ query: MF_MUTATION, variables: { m: colorMf } });
    const errs = r?.data?.metafieldsSet?.userErrors || [];
    if (errs.length) errors.push(`Color metafields: ${errs.map(e => e.message).join('; ')}`);
    else console.log(`  color metafields set: ${colorMf.map(m => m.key).join(', ')}`);
    await sleep(400);
  }

  // ---- 1b. SPEC metafields (separate batch). This store carries legacy
  //         custom.* metafield DEFINITIONS typed as product_reference (from an
  //         old CSV import — e.g. custom.finish, custom.material's siblings).
  //         A single type-incompatible key poisons the whole metafieldsSet
  //         batch, so we pre-check each spec key's existing definition type and
  //         SKIP any whose definition is incompatible with the text type we'd
  //         write — reported as a non-fatal warning, never blocks the row. ----
  const specEntries = Object.entries(specs).filter(([, v]) => v != null && String(v).trim() !== '');
  if (specEntries.length) {
    const specMf = [];
    for (const [k, v] of specEntries) {
      const wantType = specType(k);
      const defType = await definitionType(k);   // null = no definition (free text OK)
      if (defType && defType !== wantType) {
        errors.push(`Spec ${k} skipped: existing definition is '${defType}', not '${wantType}'`);
        console.log(`    SKIP custom.${k}: existing definition is '${defType}' (incompatible)`);
        continue;
      }
      // if a definition exists and matches, write with its type; else use wantType
      specMf.push({ ownerId: PID, namespace: 'custom', key: k, value: String(v), type: defType || wantType });
    }
    if (specMf.length) {
      const r = await gql({ query: MF_MUTATION, variables: { m: specMf } });
      const errs = r?.data?.metafieldsSet?.userErrors || [];
      if (errs.length) errors.push(`Spec metafields: ${errs.map(e => e.message).join('; ')}`);
      else console.log(`  spec metafields set: ${specMf.map(m => m.key).join(', ')}`);
      await sleep(400);
    }
  }

  // ---- 2. title normalization + style tags ----
  let titleColor, pattern;
  if (providedTitle) {
    // caller gave an already-normalized title; still parse pattern/color for alt tag
    const parsed = extractPatternAndColor(providedTitle);
    pattern = parsed.pattern; titleColor = parsed.color || colorName;
  } else {
    const parsed = extractPatternAndColor(origTitle);
    pattern = parsed.pattern;
    titleColor = parsed.color || colorName;
  }
  const newTitle = providedTitle || buildTitle(pattern, titleColor, vendor);

  // merge style tags + color into product tags (dedupe, case-insensitive)
  const tagSet = new Map();
  for (const t of existingTags) tagSet.set(t.toLowerCase(), t);
  const wantTags = [colorName, ...styleTags].map(t => String(t).trim()).filter(Boolean);
  for (const t of wantTags) if (!tagSet.has(t.toLowerCase())) tagSet.set(t.toLowerCase(), t);
  const mergedTags = [...tagSet.values()];

  const titleChanged = newTitle && newTitle !== origTitle;
  const tagsChanged = mergedTags.length !== existingTags.length;
  if (titleChanged || tagsChanged) {
    const input = { id: PID };
    if (titleChanged) input.title = newTitle;
    if (tagsChanged) input.tags = mergedTags;
    const r = await gql({ query: PRODUCT_UPDATE, variables: { i: input } });
    const errs = r?.data?.productUpdate?.userErrors || [];
    if (errs.length) errors.push(`Title/Tags: ${errs.map(e => e.message).join('; ')}`);
    else console.log(`  title${titleChanged ? `="${newTitle}"` : ' unchanged'}${tagsChanged ? `  tags=[${mergedTags.join(', ')}]` : ''}`);
    await sleep(400);
  } else { console.log('  title + tags already current'); }

  // ---- 3. image alt tags: "{SKU} {Pattern} {Color} designer-wallcoverings-los-angeles" ----
  const mediaEdges = p.media?.edges || [];
  const altBase = `${sku} ${pattern} ${titleColor} designer-wallcoverings-los-angeles`.replace(/\s+/g, ' ').trim();
  if (mediaEdges.length > 0) {
    const updates = [];
    for (let mi = 0; mi < mediaEdges.length; mi++) {
      const node = mediaEdges[mi].node;
      if (!node?.id) continue;
      updates.push({ id: node.id, alt: mi === 0 ? altBase : `${altBase} ${mi + 1}` });
    }
    if (updates.length) {
      const mediaJson = updates.map(m => `{id: "${m.id}", alt: "${m.alt.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"}`).join(', ');
      const r = await gql({ query: `mutation { productUpdateMedia(productId: "${PID}", media: [${mediaJson}]) { media { ... on MediaImage { id } } mediaUserErrors { message } } }` });
      const errs = r?.data?.productUpdateMedia?.mediaUserErrors || [];
      if (errs.length) errors.push(`Alt: ${errs.map(e => e.message).join('; ')}`);
      else console.log(`  alt tags set on ${updates.length} image(s): "${altBase}"`);
      await sleep(400);
    }
  } else { console.log('  no media to alt-tag'); }

  // ---- 4. stamp dw_unified.shopify_products (idempotent) ----
  // NOTE: full_monty_tags is an INTEGER column (a count, per existing schema) —
  // the color + style tag STRINGS live on the Shopify product tags above. We
  // stamp the count of tags applied so the column stays integer-valid.
  const pg = new Client({ user: process.env.PGUSER || 'stevestudio2', database: process.env.PGDATABASE || 'dw_unified', host: process.env.PGHOST || '/tmp' });
  let dbStamped = false;
  try {
    await pg.connect();
    const tagCount = wantTags.length;
    const res = await pg.query(
      `UPDATE shopify_products
         SET full_monty_at = now(),
             fm_metafields_verified = 1,
             fm_specs_at = now(),
             fm_tags_at = now(),
             full_monty_tags = $2
       WHERE shopify_id = $1`,
      [PID, tagCount]
    );
    if (res.rowCount > 0) { dbStamped = true; console.log(`  DB stamped: full_monty_at=now, fm_metafields_verified=1, fm_specs_at=now, fm_tags_at=now, full_monty_tags=${tagCount} (rows=${res.rowCount})`); }
    else { errors.push(`DB: no shopify_products row matched ${PID}`); console.log(`  DB: WARNING no row matched ${PID}`); }
  } catch (e) { errors.push(`DB: ${e.message}`); console.log(`  DB: ERROR ${e.message}`); }
  finally { try { await pg.end(); } catch {} }

  // ---- 5. re-query to VERIFY metafields landed ----
  await sleep(300);
  const vr = await gql({ query: `{ product(id:"${PID}") { metafields(first:100){ edges{ node{ namespace key value } } } } }` });
  const vmf = {};
  for (const e of (vr?.data?.product?.metafields?.edges || [])) {
    const { namespace, key, value } = e.node;
    if (namespace === 'custom') vmf[key] = value;
  }
  const gotColor = vmf['dominant_color'];
  const gotDetails = vmf['color_details'];
  const colorOk = gotColor === colorName;
  const detailsOk = colorDetails == null ? '(none provided)' : (gotDetails ? 'present' : 'MISSING');
  console.log(`VERIFY ${PID} | custom.dominant_color="${gotColor || ''}" (${colorOk ? 'OK' : 'MISMATCH'}) | custom.dominant_hex="${vmf['dominant_hex'] || ''}" | custom.color_details=${detailsOk} | db_stamped=${dbStamped}`);

  // Separate non-fatal warnings (spec keys skipped due to legacy product_reference
  // definitions on this store) from real, blocking errors.
  const warnings = errors.filter(e => /^Spec .* skipped/.test(e));
  const fatal = errors.filter(e => !/^Spec .* skipped/.test(e));
  if (warnings.length) console.log(`WARNINGS: ${warnings.join(' | ')}`);
  if (fatal.length) { console.error(`USERERRORS: ${fatal.join(' | ')}`); process.exit(1); }
  if (!colorOk) { console.error('FATAL: dominant_color verify mismatch'); process.exit(1); }
  if (colorDetails != null && !gotDetails) { console.error('FATAL: color_details did not land'); process.exit(1); }
  console.log('OK');
}

main().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });