← 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
A DW-Programming/fabricut-activate.js
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 →