← back to Carnegie Reprice

archive-redirect.mjs

169 lines

// archive-redirect.mjs — TK-10686 post-create step: archive the OLD Carnegie multi-variant
// products and 301-redirect each old handle -> its new first-colorway standalone product (target A).
// Steve APPROVED the plan 2026-08-18 (pending-approval/...-archive-redirect-gmc-GATED.md).
//
// SAFETY (mirrors reprice.mjs):
//   - dry-run by default: enumerates, joins, writes archive-run-map.json + preview, touches NOTHING.
//   - --apply : gated live write (archive old product + create redirect). Reversible.
//   - --rollback archive-run-map.json : un-archive every product + delete every created redirect.
//   - COMPLETENESS GATE: an old product is only archived if EVERY colorway of its pattern
//     (carnegie_catalog rows) already exists as a created new product (rebuild-ledger.jsonl w/ product_id).
//     Any pattern with missing replacements is SKIPPED and reported — never archived half-covered.
//   - NEW split products (tagged split-batch:TK-10686) are never touched.
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';

const DIR = new URL('.', import.meta.url).pathname;
const ENV = `${process.env.HOME}/Projects/secrets-manager/.env`;
function env(k){ const m=fs.readFileSync(ENV,'utf8').split('\n').find(l=>l.startsWith(k+'=')); return m?m.slice(k.length+1).trim().replace(/^["']|["']$/g,''):''; }
const TOKEN=env('SHOPIFY_ADMIN_TOKEN');
let SHOP=env('SHOPIFY_STORE_DOMAIN')||env('SHOPIFY_STORE'); if(SHOP&&!SHOP.includes('.'))SHOP+='.myshopify.com';
const API=`https://${SHOP}/admin/api/2024-10`;
const H={'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'};
const MODE=process.argv[2]||'dry';                 // dry | apply | rollback
const ROLLBACK_FILE=process.argv[3];
const TAG='split-batch:TK-10686';
const LEDGER=`${DIR}rebuild-ledger.jsonl`;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));

const PSQL=['/opt/homebrew/opt/postgresql@14/bin/psql','/usr/local/opt/postgresql@14/bin/psql','psql']
  .find(p=>{try{execFileSync(p,['--version'],{stdio:'ignore'});return true;}catch{return false;}})||'psql';
function q(sql){ const out=execFileSync(PSQL,['postgresql:///dw_unified?host=/tmp','-At','-F','|','-c',sql],{encoding:'utf8',maxBuffer:128*1024*1024}); return out.trim()?out.trim().split('\n').map(r=>r.split('|')):[]; }

async function shop(path,opts={},tries=5){
  for(let i=0;i<tries;i++){ const res=await fetch(`${API}${path}`,{headers:H,...opts});
    if(res.status===429){await sleep(2000*(i+1));continue;}
    if(!res.ok)throw new Error(`HTTP ${res.status} ${path} :: ${(await res.text()).slice(0,200)}`);
    return res; }
  throw new Error(`exhausted retries ${path}`);
}
async function allCarnegie(){ const out=[]; let url=`/products.json?vendor=Carnegie&limit=250`;
  while(url){ const res=await shop(url); const j=await res.json(); out.push(...(j.products||[]));
    const link=res.headers.get('link')||''; const m=link.match(/<[^>]*[?&]page_info=([^>&]+)[^>]*>;\s*rel="next"/);
    url=m?`/products.json?limit=250&page_info=${m[1]}`:null; await sleep(600); }
  return out;
}

// ---- ROLLBACK ----
if(MODE==='rollback'){
  if(!ROLLBACK_FILE||!fs.existsSync(ROLLBACK_FILE))throw new Error('rollback needs archive-run-map.json');
  const map=JSON.parse(fs.readFileSync(ROLLBACK_FILE,'utf8'));
  console.log(`[rollback] un-archiving ${map.archived.length} products + deleting ${map.redirects.length} redirects…`);
  // TK-10792 note: rollback re-activates OLD multi-variant products that were intentionally
  // archived. These are original Carnegie pattern pages with real vendor mfr_skus (pre-split),
  // NOT new DWAG-* products. mfr_sku gate intentionally does not apply to this undo path.
  // TK-11547 WEIGHT GO-LIVE GUARD (Steve's TK-11414 rule: never ACTIVE at zero/missing weight —
  // zero weight collapses an order into the lowest weight tier / free-shipping band and mis-costs
  // freight). Found by shopify-activator-register (TK-11498); this file was never in the
  // TK-11414/TK-11471 field of view.
  //
  // HEAL-THEN-PROCEED-LOUDLY, deliberately NOT hold. Every other site gated under TK-11547 HOLDS,
  // but this is an UNDO path: it restores products that were intentionally archived. A gate that
  // can block a rollback is worse than the defect it prevents — it strands you mid-incident with
  // no way back. So this never refuses to un-archive. It fills the approved default first, and if
  // that fails it un-archives ANYWAY and says so loudly, leaving dw-active-weight-canary (daily)
  // as the backstop. Silence is the only outcome ruled out.
  //
  // Heal goes through the REST variant field because this script authenticates with the NARROW
  // SHOPIFY_ADMIN_TOKEN, which lacks write_inventory (the GraphQL inventoryItemUpdate path 403s).
  const W_SAMPLE_LB = 0.25, W_DEFAULT_LB = 3.0;   // TK-11414-approved defaults
  const isSample = v => /sample|memo/i.test(String(v.title||v.option1||'')) || /-sample$/i.test(String(v.sku||''))
                        || Math.abs(Number(v.price)-4.25) < 0.01;
  let healed=0, unhealed=[];
  for(const a of map.archived){
    try{
      const pr = await (await shop(`/products/${a.old_id}.json`)).json();
      for(const v of (pr.product?.variants||[])){
        const w = Number(v.weight);
        const u = String(v.weight_unit||'lb').toLowerCase();
        const lb = u.startsWith('kg') ? w*2.20462 : (u==='g'||u.startsWith('gram')) ? w/453.59237 : u==='oz' ? w/16 : w;
        if(Number.isFinite(lb) && lb > 0) continue;
        const target = isSample(v) ? W_SAMPLE_LB : W_DEFAULT_LB;
        await shop(`/variants/${v.id}.json`,{method:'PUT',body:JSON.stringify({variant:{id:v.id,weight:target,weight_unit:'lb'}})});
        await sleep(400);
        // RE-VERIFY: the mutation's own 200 is not evidence the weight is set.
        const re = await (await shop(`/variants/${v.id}.json`)).json();
        const rw = Number(re.variant?.weight);
        if(Number.isFinite(rw) && rw > 0) healed++; else unhealed.push(`${a.old_id}/${v.sku||v.id}`);
      }
    }catch(e){ unhealed.push(`${a.old_id}(read/heal error: ${e.message})`); }
    await shop(`/products/${a.old_id}.json`,{method:'PUT',body:JSON.stringify({product:{id:a.old_id,status:'active'}})}); await sleep(550);
  }
  if(healed) console.log(`[rollback] TK-11547: healed ${healed} zero-weight variant(s) to the approved default before un-archiving`);
  if(unhealed.length) console.error(`[rollback] ⚠️  TK-11547: ${unhealed.length} variant(s) are LIVE AT ZERO WEIGHT — un-archived anyway (an undo must never be blocked). dw-active-weight-canary will flag these within 24h; fix with scripts/tk11471-weight-backfill.py. ${unhealed.slice(0,15).join(', ')}`);
  for(const r of map.redirects){ if(r.redirect_id){ try{await shop(`/redirects/${r.redirect_id}.json`,{method:'DELETE'});}catch{} await sleep(400);} }
  console.log(`[rollback] done`); process.exit(0);
}

// ---- BUILD: pattern -> colorways expected (carnegie_catalog) ----
const patColorways=new Map();           // pattern_name -> [{dw_sku,color_number}]
for(const [pat,sku,cn] of q(`select pattern_name, dw_sku, color_number from carnegie_catalog order by pattern_name, dw_sku`)){
  if(!patColorways.has(pat))patColorways.set(pat,[]); patColorways.get(pat).push({sku,cn});
}
// ---- BUILD: dw_sku -> new product (from ledger, only rows with a product_id) ----
const newBySku=new Map();               // dw_sku -> {product_id, handle}
if(fs.existsSync(LEDGER)) for(const l of fs.readFileSync(LEDGER,'utf8').split('\n')){ if(!l.trim())continue; try{const r=JSON.parse(l); if(r.product_id&&r.dw_sku)newBySku.set(r.dw_sku,{id:r.product_id,handle:r.handle});}catch{} }
// pattern -> first-colorway new handle (lowest color_number that exists as a new product)
function firstColorwayNewHandle(pat){ const cw=patColorways.get(pat)||[]; for(const c of cw){ const np=newBySku.get(c.sku); if(np)return np.handle; } return null; }
function patternComplete(pat){ const cw=patColorways.get(pat)||[]; if(!cw.length)return false; return cw.every(c=>newBySku.has(c.sku)); }

// ---- ENUMERATE old multi-variant Carnegie products (NOT tagged split-batch, has a "Color" option) ----
const products=await allCarnegie();
const old=products.filter(p=>{
  const tags=(p.tags||'').includes(TAG); if(tags)return false;                 // exclude new split products
  const hasColor=(p.options||[]).some(o=>/^colou?r$/i.test(o.name));           // original had a Color option
  return hasColor;
});
console.log(`[enum] live Carnegie products: ${products.length} | old multi-variant (Color option, not split-tagged): ${old.length}`);

// map each old product to its pattern via its non-sample variant SKUs -> carnegie_catalog pattern
const skuPattern=new Map(); for(const [pat,sku] of q(`select pattern_name, dw_sku from carnegie_catalog`)) skuPattern.set(sku,pat);
const ready=[], blocked=[], nopattern=[];
for(const p of old){
  const skus=(p.variants||[]).map(v=>v.sku).filter(Boolean).filter(s=>!/sample/i.test(s));
  const pats=[...new Set(skus.map(s=>skuPattern.get(s)).filter(Boolean))];
  const pat=pats[0];
  if(!pat){ nopattern.push({handle:p.handle,id:p.id}); continue; }
  const target=firstColorwayNewHandle(pat);
  const complete=patternComplete(pat) && target;
  const rec={old_id:p.id, old_handle:p.handle, pattern:pat, expected:(patColorways.get(pat)||[]).length,
             present:(patColorways.get(pat)||[]).filter(c=>newBySku.has(c.sku)).length, target_handle:target};
  (complete?ready:blocked).push(rec);
}

const stamp=q(`select to_char(now(),'YYYYMMDD-HH24MISS')`)[0][0];
const preview={ ts:new Date().toISOString(), mode:MODE, redirect_target:'A (old -> new first-colorway product)',
  old_total:old.length, ready:ready.length, blocked:blocked.length, no_pattern:nopattern.length,
  ready_sample:ready.slice(0,8), blocked_sample:blocked.slice(0,8), no_pattern_sample:nopattern.slice(0,5) };
fs.writeFileSync(`${DIR}archive-preview-${stamp}.json`,JSON.stringify(preview,null,2));
console.log(`\n=== ARCHIVE+REDIRECT PLAN (${MODE.toUpperCase()}) — target A ===`);
console.log(`  old multi-variant products : ${old.length}`);
console.log(`  READY (all colorways created): ${ready.length}`);
console.log(`  BLOCKED (replacements incomplete): ${blocked.length}`);
console.log(`  no-pattern (review): ${nopattern.length}`);
for(const r of ready.slice(0,8)) console.log(`    ARCHIVE ${r.old_handle.padEnd(34)} -> /products/${r.target_handle}`);
if(blocked.length) console.log(`  ⚠ ${blocked.length} blocked e.g. ${blocked.slice(0,3).map(b=>`${b.old_handle}(${b.present}/${b.expected})`).join(', ')}`);

if(MODE!=='apply'){ console.log(`\n[dry-run] nothing written. preview: archive-preview-${stamp}.json. Re-run 'apply' AFTER creates complete + Steve go.`); process.exit(0); }

// ---- APPLY (gated): archive READY old products + create redirect ----
if(!ready.length){ console.log('[apply] nothing READY yet (creates incomplete) — refusing to archive half-covered patterns.'); process.exit(0); }
console.log(`\n[apply] archiving ${ready.length} old products + creating redirects…`);
const runmap={ts:new Date().toISOString(),archived:[],redirects:[]};
let ok=0,err=0;
for(const r of ready){
  try{
    await shop(`/products/${r.old_id}.json`,{method:'PUT',body:JSON.stringify({product:{id:r.old_id,status:'archived'}})});
    runmap.archived.push({old_id:r.old_id,old_handle:r.old_handle});
    const rr=await shop(`/redirects.json`,{method:'POST',body:JSON.stringify({redirect:{path:`/products/${r.old_handle}`,target:`/products/${r.target_handle}`}})});
    const rid=(await rr.json())?.redirect?.id||null;
    runmap.redirects.push({redirect_id:rid,path:`/products/${r.old_handle}`,target:`/products/${r.target_handle}`});
    ok++;
  }catch(e){ err++; console.log(`  ERR ${r.old_handle}: ${String(e.message||e).slice(0,120)}`); }
  if((ok+err)%25===0)console.log(`  ${ok+err}/${ready.length} (ok ${ok}, err ${err})`);
  await sleep(600);
}
fs.writeFileSync(`${DIR}archive-run-map.json`,JSON.stringify(runmap,null,2));
console.log(`[apply] done: ${ok} archived+redirected, ${err} err. Rollback: node archive-redirect.mjs rollback archive-run-map.json`);
console.log(`[apply] ${blocked.length} patterns still blocked — re-run apply after remaining creates land.`);