[object Object]

← back to Designer Wallcoverings

fabricut-activate: same image-less-activation guard as daily-post (TK-10807)

7a2091d021a0b2e83317ee57816b9bea785eeaaf · 2026-08-24 11:36:43 -0700 · Steve

GET /products/:id/images.json before flipping active; keep DRAFT + ledger if 0
images landed on Shopify. Closes the last remaining poster with the source-image-
validated-but-upload-unverified gap (cadence/activate-gated + kravet-3b already
validate against LIVE product images, so no change needed there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 7a2091d021a0b2e83317ee57816b9bea785eeaaf
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Aug 24 11:36:43 2026 -0700

    fabricut-activate: same image-less-activation guard as daily-post (TK-10807)
    
    GET /products/:id/images.json before flipping active; keep DRAFT + ledger if 0
    images landed on Shopify. Closes the last remaining poster with the source-image-
    validated-but-upload-unverified gap (cadence/activate-gated + kravet-3b already
    validate against LIVE product images, so no change needed there).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 DW-Programming/fabricut-activate.js | 100 ++++++++++++++++++++++++++++++++++++
 1 file changed, 100 insertions(+)

diff --git a/DW-Programming/fabricut-activate.js b/DW-Programming/fabricut-activate.js
new file mode 100644
index 00000000..fcf53450
--- /dev/null
+++ b/DW-Programming/fabricut-activate.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+/**
+ * Fabricut — Activation (TK-10513). Draft → Active, GMC-leak-safe.
+ * Steve "go" 2026-08-19. For each on_shopify Fabricut draft:
+ *   1. validateBeforeActivate (width/desc/image/sample/title guards) — fail → keep DRAFT + tag.
+ *   2. apply commercial min-order metafield (v_prods_quantity_order_min): min_order_yards(30) or roll_yards.
+ *   3. status → active (REST).
+ *   4. publish to ALL channels EXCEPT "Google & YouTube" (pub 29646651457) — Steve GMC policy:
+ *      Shopify auto-syncs minVariantPrice ($4.25 sample) to Merchant Center = the leak. Google
+ *      is fed by the controlled TSV feed (real retail) only.
+ *
+ * Usage: node fabricut-activate.js [--dry-run] [--limit N]
+ */
+const { Pool } = require('pg');
+const https = require('https');
+const { validateBeforeActivate } = require('/Users/macstudio3/Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js');
+const fs = require('fs');
+
+const STORE='designer-laboratory-sandbox.myshopify.com', TOKEN=process.env.SHOPIFY_ADMIN_TOKEN, VER='2024-10';
+const GYT_PUB='gid://shopify/Publication/29646651457';   // Google & YouTube — EXCLUDE
+const pool=new Pool({host:'/tmp',database:'dw_unified'});
+const DELAY=500; const st={total:0,activated:0,kept_draft:0,failed:0,reasons:{}};
+const LEDGER='/Users/macstudio3/.claude/yolo-queue/executed-reversible/fabricut-activate-ledger.jsonl';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+function toTitleCase(s){if(!s)return'';const m=new Set(['a','an','the','in','on','at','for','and','but','or','of','to','is']);return String(s).toLowerCase().split(/\s+/).filter(Boolean).map((w,i)=>i===0||!m.has(w)?w.charAt(0).toUpperCase()+w.slice(1):w).join(' ');}
+function cleanWP(s){if(!s)return s;const B=['Apartment','Boråstapeter','China Seas','DW Exclusive','Edge','Fentucci','Grasscloth','Laura Ashley','Malibu','MC Escher','Missoni','Nicolette Mayer','PS Removable','Ralph Lauren','Roberto Cavalli','Scalamandre','Schumacher'];const br=new RegExp('('+B.map(b=>b.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('|')+')\\s+Wallpaper','gi');const S=' ',h=[];let o=String(s).replace(br,m=>{h.push(m);return S+(h.length-1)+S;});o=o.replace(/\bWallpaper(s?)\b(?!ed|ing)/gi,(m,p)=>{const b=p?'wallcoverings':'wallcovering';return m===m.toUpperCase()?b.toUpperCase():m[0]===m[0].toUpperCase()?b[0].toUpperCase()+b.slice(1):b;});h.forEach((x,i)=>{o=o.split(S+i+S).join(x);});return o;}
+
+function rest(method,path,body){return new Promise((res,rej)=>{const d=body?JSON.stringify(body):null;
+  const req=https.request({hostname:STORE,path:`/admin/api/${VER}${path}`,method,headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json',...(d?{'Content-Length':Buffer.byteLength(d)}:{})},timeout:30000},
+    r=>{let b='';r.on('data',c=>b+=c);r.on('end',()=>{if(r.statusCode===429){res({rl:true});return;}if(r.statusCode>=400){rej(new Error(`${r.statusCode}:${b.slice(0,200)}`));return;}try{res(JSON.parse(b));}catch{res({});}});});
+  req.on('error',rej);req.on('timeout',()=>{req.destroy();rej(new Error('timeout'));});if(d)req.write(d);req.end();});}
+async function restR(m,p,b,n){n=n||0;try{const r=await rest(m,p,b);if(r.rl){await sleep(1500);return restR(m,p,b,n);}return r;}catch(e){if(n<2){await sleep(1500*(n+1));return restR(m,p,b,n+1);}throw e;}}
+function gql(query,variables){return new Promise(res=>{const d=JSON.stringify({query,variables});const req=https.request({host:STORE,path:`/admin/api/${VER}/graphql.json`,method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json','Content-Length':Buffer.byteLength(d)}},r=>{let b='';r.on('data',c=>b+=c);r.on('end',()=>{try{res(JSON.parse(b));}catch{res(null);}});});req.on('error',()=>res(null));req.write(d);req.end();});}
+let _pubs=null;
+async function loadPubs(){if(_pubs)return _pubs;const r=await gql(`{publications(first:50){edges{node{id name}}}}`,{});
+  _pubs=(r?.data?.publications?.edges||[]).map(e=>e.node).filter(p=>p.id!==GYT_PUB);return _pubs;}
+async function publishExGYT(gid){const pubs=await loadPubs();if(!pubs.length)return false;
+  await gql(`mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{field message}}}`,{id:gid,input:pubs.map(p=>({publicationId:p.id}))});await sleep(300);return true;}
+
+function validate(row){
+  const title=cleanWP(`${toTitleCase(row.pattern_name)} ${toTitleCase(row.color_name)} | Fabricut`);
+  return {shape:{title,vendor:'Fabricut',tags:['Fabricut'],dwSku:row.dw_sku,
+    descriptionHtml:row.ai_description||'',
+    specs:{width:row.width,length:row.length,repeat:row.repeat_v,material:row.material,unitOfMeasure:row.product_type==='Commercial Wallcovering'?'Priced Per Yard':'Priced Per Single Roll'},
+    vendorSpecs:{width:row.width,length:row.length,repeat:row.repeat_v,material:row.material,unitOfMeasure:true},
+    images:[row.image_url],vendorImages:[row.image_url],
+    variants:[{sku:row.dw_sku},{sku:row.dw_sku+'-Sample'}]},
+    result:validateBeforeActivate({title,vendor:'Fabricut',tags:['Fabricut'],dwSku:row.dw_sku,descriptionHtml:row.ai_description||'',
+      specs:{width:row.width,length:row.length,repeat:row.repeat_v,material:row.material,unitOfMeasure:row.product_type==='Commercial Wallcovering'?'Priced Per Yard':'Priced Per Single Roll'},
+      vendorSpecs:{width:row.width,length:row.length,repeat:row.repeat_v,material:row.material,unitOfMeasure:'x'},
+      images:[row.image_url],vendorImages:[row.image_url],variants:[{sku:row.dw_sku},{sku:row.dw_sku+'-Sample'}]})};
+}
+
+async function activate(row,dry){
+  const {result}=validate(row);
+  if(!result.ok){ st.kept_draft++; result.reasons.forEach(r=>st.reasons[r]=(st.reasons[r]||0)+1);
+    if(!dry && result.tags.length){ const gid=`gid://shopify/Product/${row.shopify_product_id}`;
+      await gql(`mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{message}}}`,{id:gid,tags:result.tags}); }
+    return {dw:row.dw_sku,kept_draft:true,reasons:result.reasons}; }
+  if(dry){ st.activated++; return {dw:row.dw_sku,would_activate:true}; }
+  const pid=row.shopify_product_id, gid=`gid://shopify/Product/${pid}`;
+  // TK-10807 guard: never activate an image-less product. validate() checks the SOURCE image
+  // (row.image_url), but the upload to Shopify can fail silently at the 500GB storage cap
+  // (dw-shopify-storage-canary), leaving an ACTIVE product with 0 images (DWFC-23001x class).
+  const _imgResp=await restR('GET',`/products/${pid}/images.json`);
+  const _imgCount=(_imgResp&&_imgResp.images&&_imgResp.images.length)||0;
+  if(_imgCount<1){ st.kept_draft++; st.reasons['no_image_on_shopify']=(st.reasons['no_image_on_shopify']||0)+1;
+    fs.appendFileSync(LEDGER,JSON.stringify({ts:new Date().toISOString(),dw:row.dw_sku,pid,action:'kept_draft:0_images_on_shopify'})+'\n');
+    return {dw:row.dw_sku,kept_draft:true,reason:'no_image_on_shopify'}; }
+  // commercial min-order metafield
+  const minY = row.min_order_yards>0 ? row.min_order_yards : (row.roll_yards||null);
+  if(minY){ await restR('POST',`/products/${pid}/metafields.json`,{metafield:{namespace:'global',key:'v_prods_quantity_order_min',value:String(minY),type:'single_line_text_field'}}).catch(()=>{}); }
+  // activate
+  await restR('PUT',`/products/${pid}.json`,{product:{id:Number(pid),status:'active'}});
+  // publish to all channels EXCEPT Google & YouTube
+  await publishExGYT(gid);
+  st.activated++;
+  fs.appendFileSync(LEDGER,JSON.stringify({ts:new Date().toISOString(),dw:row.dw_sku,pid,action:'activated+exGYT'})+'\n');
+  return {dw:row.dw_sku,activated:true};
+}
+
+async function main(){
+  const args=process.argv.slice(2),dry=args.includes('--dry-run');
+  let limit=null;const li=args.indexOf('--limit');if(li>=0&&args[li+1])limit=parseInt(args[li+1]);
+  if(!TOKEN){console.error('no SHOPIFY_ADMIN_TOKEN');process.exit(1);}
+  console.log(`=== Fabricut ACTIVATION ===${dry?' [DRY]':''}`);
+  let q=`SELECT * FROM fabricut_catalog WHERE product_url ~ 'fabricut.com' AND on_shopify=true AND shopify_product_id IS NOT NULL ORDER BY dw_sku`;
+  if(limit)q+=` LIMIT ${limit}`;
+  const {rows}=await pool.query(q);st.total=rows.length;console.log(`Candidates: ${st.total}\n`);
+  const t0=Date.now();
+  for(let i=0;i<rows.length;i++){
+    try{await activate(rows[i],dry);}catch(e){st.failed++;console.error(`✗ ${rows[i].dw_sku}: ${e.message}`);}
+    if((i+1)%25===0||i===rows.length-1)console.log(`[${(((i+1)/rows.length)*100).toFixed(1)}%] ${i+1}/${rows.length} | active:${st.activated} draft:${st.kept_draft} fail:${st.failed} | ${((Date.now()-t0)/1000).toFixed(0)}s`);
+    if(!dry&&i<rows.length-1)await sleep(DELAY);
+  }
+  console.log(`\n=== DONE ===${dry?' [DRY]':''} total:${st.total} activated:${st.activated} kept_draft:${st.kept_draft} failed:${st.failed}`);
+  if(Object.keys(st.reasons).length){console.log('draft reasons:');Object.entries(st.reasons).sort((a,b)=>b[1]-a[1]).forEach(([r,n])=>console.log(`  ${n}x ${r}`));}
+  await pool.end();
+}
+main().catch(e=>{console.error('Fatal:',e);pool.end();process.exit(1);});

← 283b8478 fabricut poster: never activate an image-less product (TK-10  ·  back to Designer Wallcoverings  ·  auto-data-snapshot: 2026-08-24T11:44:27 (1 data files) — DW- 4aba82c2 →