← back to Designer Wallcoverings

post_ere_eur.js

118 lines

#!/usr/bin/env node
/* Post the staged ERE-/EUR- re-image + enrichment + archive to the LIVE store.
 * Steve-approved 2026-06-26. Reads ere_dg_map + eur_map from dw_unified.
 * Actions per product:
 *   - re-imaged (in_db/in_db_cleaned/crawled): add mfr photo as featured image,
 *     set descriptionHtml = AI description, merge AI color/style/pattern tags.
 *   - discontinued: productUpdate status=ARCHIVED.
 *   - keep_current_image: skip (working image, no change).
 * Modes:  (default) DRY-RUN   |   --pilot N (write only first N)   |   --commit (all)
 * Image/status/metafield updates do NOT consume the daily variant cap (no creates).
 */
const { Client } = require('pg');
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
const fs = require('fs');
function envToken() {
  for (const f of ['/Users/macstudio3/Projects/Designer-Wallcoverings/.env',
                   '/Users/macstudio3/Projects/Designer-Wallcoverings/shopify/scripts/cadence/.env']) {
    try { const m = fs.readFileSync(f,'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m); if (m) return m[1].trim(); } catch {}
  }
  throw new Error('no SHOPIFY_ADMIN_TOKEN');
}
const TOKEN = envToken();
const argv = process.argv.slice(2);
const COMMIT = argv.includes('--commit');
const pilotIdx = argv.indexOf('--pilot');
const PILOT_N = pilotIdx >= 0 ? parseInt(argv[pilotIdx+1]||'3',10) : 0;
const sleep = ms => new Promise(r=>setTimeout(r,ms));

async function gql(query, variables, tries=4) {
  for (let a=0;a<tries;a++){
    try {
      const res = await fetch(ENDPOINT, { method:'POST',
        headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},
        body: JSON.stringify({query,variables}) });
      if (res.status===429 || res.status>=500){ await sleep(1500*(a+1)); continue; }
      const j = await res.json();
      if (j.errors){ // throttle?
        if (JSON.stringify(j.errors).includes('THROTTLED')){ await sleep(2000*(a+1)); continue; }
        throw new Error(JSON.stringify(j.errors).slice(0,200));
      }
      return j.data;
    } catch(e){ if(a===tries-1) throw e; await sleep(1500*(a+1)); }
  }
}
const M_ADD = `mutation($productId:ID!,$media:[CreateMediaInput!]!){
  productCreateMedia(productId:$productId,media:$media){ media{ id status } mediaUserErrors{ field message } } }`;
const M_UPDATE = `mutation($input:ProductInput!){
  productUpdate(input:$input){ product{ id status } userErrors{ field message } } }`;

function tagList(...jsons){
  const out=[];
  for (const j of jsons){ if(!j) continue; let v=j; if(typeof j==='string'){try{v=JSON.parse(j)}catch{continue}}
    if(Array.isArray(v)) for(const x of v){ if(typeof x==='string') out.push(x); else if(x&&x.name) out.push(x.name); } }
  return out;
}

async function main(){
  const pg = new Client({ host:'/tmp', database:'dw_unified' });
  await pg.connect();
  // Build unified work-list from both staging tables
  const ere = (await pg.query(`
    SELECT ere_sku sku, shopify_id gid, title, source, planned_status,
           new_image, ai_description descr, ai_tags, ai_styles, ai_patterns, ai_colors
    FROM ere_dg_map`)).rows.map(r=>({...r,line:'ERE'}));
  const eur = (await pg.query(`
    SELECT ere_sku sku, shopify_id gid, title, source, planned_status,
           new_image, src_ai_desc descr, NULL ai_tags, src_ai_styles ai_styles,
           src_ai_patterns ai_patterns, src_ai_colors ai_colors
    FROM eur_map`)).rows.map(r=>({...r,line:'EUR'}));
  // current tags from mirror to merge (productUpdate replaces full tag set)
  const tagRows = (await pg.query(`SELECT dw_sku, tags FROM shopify_products WHERE dw_sku ILIKE 'ERE-%' OR dw_sku ILIKE 'EUR-%'`)).rows;
  const curTags = {}; for(const t of tagRows){ if(!curTags[t.dw_sku]) curTags[t.dw_sku]=t.tags||''; }

  let work = [...ere,...eur].filter(r=>r.gid);
  const reimage = work.filter(r=>['in_db','in_db_cleaned','crawled'].includes(r.source) && r.new_image);
  const archive = work.filter(r=>r.planned_status==='ARCHIVED');
  const skip    = work.filter(r=>r.source==='keep_current_image');

  console.log(`WORK: reimage=${reimage.length}  archive=${archive.length}  keep=${skip.length}`);
  let queue = [...reimage.map(r=>({...r,act:'reimage'})), ...archive.map(r=>({...r,act:'archive'}))];
  if (PILOT_N) queue = [ ...reimage.slice(0,Math.max(1,PILOT_N-1)).map(r=>({...r,act:'reimage'})),
                         ...archive.slice(0,1).map(r=>({...r,act:'archive'})) ];
  const live = COMMIT || PILOT_N>0;
  console.log(`MODE: ${COMMIT?'COMMIT (all)':PILOT_N?`PILOT (${queue.length})`:'DRY-RUN'}  | live writes: ${live}`);

  let done=0, errs=0;
  for (const r of queue){
    try{
      if (r.act==='archive'){
        if(!live){ console.log(`  [dry] ARCHIVE ${r.sku}`); continue; }
        const d = await gql(M_UPDATE,{input:{id:r.gid,status:'ARCHIVED'}});
        const ue=d.productUpdate?.userErrors||[]; if(ue.length) throw new Error(JSON.stringify(ue));
        console.log(`  ✓ ARCHIVED ${r.sku}`); done++;
      } else {
        // 1) add mfr image as featured
        const mergedTags = Array.from(new Set([
          ...(curTags[r.sku]||'').split(',').map(s=>s.trim()).filter(Boolean),
          ...tagList(r.ai_tags,r.ai_colors,r.ai_styles,r.ai_patterns)
        ]));
        if(!live){ console.log(`  [dry] REIMAGE ${r.sku} img=${(r.new_image||'').slice(-22)} tags+${mergedTags.length} desc=${r.descr?'Y':'N'}`); continue; }
        const add = await gql(M_ADD,{productId:r.gid,media:[{originalSource:r.new_image,mediaContentType:'IMAGE',alt:r.title}]});
        const me=add.productCreateMedia?.mediaUserErrors||[]; if(me.length) throw new Error('media:'+JSON.stringify(me));
        // 2) update description + tags
        const inp={id:r.gid, tags:mergedTags};
        if(r.descr) inp.descriptionHtml=r.descr;
        const upd = await gql(M_UPDATE,{input:inp});
        const ue=upd.productUpdate?.userErrors||[]; if(ue.length) throw new Error('update:'+JSON.stringify(ue));
        console.log(`  ✓ REIMAGE ${r.sku} (+img, tags=${mergedTags.length}, desc=${r.descr?'Y':'N'})`); done++;
      }
      await sleep(180); // ~5/s, well under Plus limits
    }catch(e){ errs++; console.log(`  ✗ ${r.sku} ${r.act} ERR ${String(e).slice(0,120)}`); }
  }
  console.log(`\nDONE: ${done} written, ${errs} errors (of ${queue.length})`);
  await pg.end();
}
main().catch(e=>{console.error(e);process.exit(1)});