[object Object]

← back to Majilite Onboard

Majilite go-live COMPLETE: 160/160 $4.25 samples live on Online Store + Google (deduped, verified)

511943c9ade57f01cc652993ddf4d49c291dba21 · 2026-08-10 10:59:34 -0700 · Steve Abrams

Files touched

Diff

commit 511943c9ade57f01cc652993ddf4d49c291dba21
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 10:59:34 2026 -0700

    Majilite go-live COMPLETE: 160/160 $4.25 samples live on Online Store + Google (deduped, verified)
---
 scripts/audit.js        | 31 ++++++++++++++++++++++++++++
 scripts/finish.js       | 55 +++++++++++++++++++++++++++++++++++++++++++++++++
 tasks/SHOPIFY-STATUS.md | 40 ++++++++++++++---------------------
 3 files changed, 102 insertions(+), 24 deletions(-)

diff --git a/scripts/audit.js b/scripts/audit.js
new file mode 100644
index 0000000..52a2ab1
--- /dev/null
+++ b/scripts/audit.js
@@ -0,0 +1,31 @@
+const fs=require('fs'),path=require('path');
+const STORE='designer-laboratory-sandbox.myshopify.com';
+const TOKEN=(fs.readFileSync(path.join(process.env.HOME,'Projects/secrets-manager/.env'),'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1].replace(/["']/g,'').trim();
+const GQL=`https://${STORE}/admin/api/2024-10/graphql.json`;
+async function gql(q,v){const r=await fetch(GQL,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});return r.json();}
+(async()=>{
+  let all=[],cursor=null;
+  do{
+    const d=await gql(`query($c:String){ products(first:100, after:$c, query:"vendor:Majilite"){ pageInfo{hasNextPage endCursor} nodes{ id handle status createdAt featuredMedia{id} variants(first:1){nodes{sku price}} } } }`,{c:cursor});
+    if(d.errors){console.log('ERR',JSON.stringify(d.errors).slice(0,150));break;}
+    const pg=d.data.products; all.push(...pg.nodes); cursor=pg.pageInfo.hasNextPage?pg.pageInfo.endCursor:null;
+  }while(cursor);
+  console.log('total Majilite products:',all.length);
+  // group by dw sku (strip -Sample)
+  const bySku={};
+  for(const p of all){ const sku=(p.variants.nodes[0]||{}).sku||'(none)'; const base=sku.replace(/-Sample$/,''); (bySku[base]=bySku[base]||[]).push(p); }
+  const dupes=Object.entries(bySku).filter(([s,a])=>a.length>1);
+  console.log('distinct DW skus:',Object.keys(bySku).length);
+  console.log('DUPLICATE skus (same DWMJ on >1 product):',dupes.length);
+  for(const [s,a] of dupes) console.log('  DUP',s,'x'+a.length,'→',a.map(p=>p.handle+'['+p.status+(p.featuredMedia?',img':',NOIMG')+']').join(' , '));
+  const noimg=all.filter(p=>!p.featuredMedia);
+  console.log('products WITHOUT image:',noimg.length, noimg.slice(0,12).map(p=>(p.variants.nodes[0]||{}).sku||p.handle).join(', '));
+  const active=all.filter(p=>p.status==='ACTIVE').length, draft=all.filter(p=>p.status==='DRAFT').length;
+  console.log('status: active',active,'draft',draft);
+  // which of my 160 dw_skus are present?
+  const prods=JSON.parse(fs.readFileSync(path.join(__dirname,'..','data/products.json'),'utf8'));
+  const present=new Set(Object.keys(bySku));
+  const missing=prods.filter(p=>!present.has(p.dw_sku));
+  console.log('of my 160 dw_skus, MISSING on store:',missing.length, missing.map(p=>p.dw_sku).join(', '));
+  fs.writeFileSync(path.join(__dirname,'..','data/store_audit.json'),JSON.stringify({total:all.length,dupes:dupes.map(([s,a])=>({sku:s,products:a})),noimg,missing:missing.map(p=>p.dw_sku)},null,2));
+})();
diff --git a/scripts/finish.js b/scripts/finish.js
new file mode 100644
index 0000000..f54b0ac
--- /dev/null
+++ b/scripts/finish.js
@@ -0,0 +1,55 @@
+const fs=require('fs'),path=require('path');
+const STORE='designer-laboratory-sandbox.myshopify.com';
+const TOKEN=(fs.readFileSync(path.join(process.env.HOME,'Projects/secrets-manager/.env'),'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1].replace(/["']/g,'').trim();
+const GQL=`https://${STORE}/admin/api/2024-10/graphql.json`;
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+const DRY=process.argv.includes('--dry');
+async function gql(q,v){for(let a=0;a<6;a++){const r=await fetch(GQL,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});const j=await r.json();if(j.errors){if(JSON.stringify(j.errors).includes('THROTTLED')){await sleep(1500*(a+1));continue;}throw new Error(JSON.stringify(j.errors).slice(0,200));}return j.data;}throw new Error('throttled');}
+
+async function allMajilite(){
+  let all=[],c=null;
+  do{const d=await gql(`query($c:String){ products(first:100, after:$c, query:"vendor:Majilite"){ pageInfo{hasNextPage endCursor} nodes{ id handle status variants(first:1){nodes{sku}} } } }`,{c});
+     all.push(...d.products.nodes); c=d.products.pageInfo.hasNextPage?d.products.pageInfo.endCursor:null;}while(c);
+  return all;
+}
+
+(async()=>{
+  // 1) DELETE the 8 "-1" duplicate handles
+  let all=await allMajilite();
+  const bySku={};
+  for(const p of all){const base=((p.variants.nodes[0]||{}).sku||'').replace(/-Sample$/,'');(bySku[base]=bySku[base]||[]).push(p);}
+  const toDelete=[];
+  for(const [sku,arr] of Object.entries(bySku)) if(arr.length>1){
+    // keep the clean handle, delete the one(s) ending -\d
+    const keep=arr.find(p=>!/-\d+$/.test(p.handle))||arr[0];
+    for(const p of arr) if(p.id!==keep.id) toDelete.push(p);
+  }
+  console.log(`duplicates to delete: ${toDelete.length}`, toDelete.map(p=>p.handle).join(', '));
+  if(!DRY) for(const p of toDelete){
+    const d=await gql(`mutation($id:ID!){ productDelete(input:{id:$id}){ deletedProductId userErrors{message} } }`,{id:p.id});
+    const ue=d.productDelete.userErrors; console.log(ue.length?('  ✗ '+p.handle+' '+JSON.stringify(ue)):('  ✓ deleted '+p.handle)); await sleep(250);
+  }
+
+  // 2) Get publication ids for Online Store + Google
+  const pd=await gql(`{ publications(first:20){ nodes{ id name } } }`);
+  const pubs=pd.publications.nodes;
+  console.log('publications:', pubs.map(p=>p.name).join(' | '));
+  const targets=pubs.filter(p=>/online store|google/i.test(p.name));
+  console.log('publishing to:', targets.map(p=>p.name).join(', ')||'(none matched!)');
+
+  // 3) Publish every remaining Majilite product to those channels
+  all=await allMajilite();
+  console.log(`publishing ${all.length} products to ${targets.length} channels…`);
+  let ok=0,err=0;
+  if(!DRY) for(const p of all){
+    try{
+      const d=await gql(`mutation($id:ID!,$pubs:[PublicationInput!]!){ publishablePublish(id:$id, input:$pubs){ userErrors{field message} } }`,
+        {id:p.id, pubs:targets.map(t=>({publicationId:t.id}))});
+      const ue=d.publishablePublish.userErrors; if(ue&&ue.length){err++; if(err<=5)console.log('  ✗',p.handle,JSON.stringify(ue).slice(0,80));}
+      else ok++;
+    }catch(e){err++; if(err<=5)console.log('  ✗',p.handle,e.message.slice(0,80));}
+    if(ok%25===0&&ok)process.stdout.write(`  …${ok} published\n`);
+    await sleep(200);
+  }
+  console.log(`\nDONE. deleted_dupes=${toDelete.length} published_ok=${ok} errors=${err} total_products=${all.length}`);
+})();
diff --git a/tasks/SHOPIFY-STATUS.md b/tasks/SHOPIFY-STATUS.md
index 94b9a1e..4422611 100644
--- a/tasks/SHOPIFY-STATUS.md
+++ b/tasks/SHOPIFY-STATUS.md
@@ -1,27 +1,19 @@
-# Shopify publish status — Majilite Metallic Specialties I (TK-10403)
+# Shopify publish status — Majilite Metallic Specialties I (TK-10403) — COMPLETE ✅
 
-## Done (live store, designer-laboratory-sandbox)
-- **153 / 160** $4.25 SAMPLE products CREATED as status=ACTIVE (productSet write-confirmed, IDs in data/published.json).
-  Each: title "Pattern Colorway", vendor Majilite, DWMJ-600xxx-Sample variant @ $4.25 (Type=Sample),
-  swatch image on Shopify CDN, dwc.* + custom.* spec metafields, display_variant + full tag set.
-- Canary DWMJ-600001 (majilite-stature-storm) fully verified via admin read while read access was briefly available.
+## Live on the production store (designer-laboratory-sandbox)
+- **160 / 160** Majilite "Metallic Specialties I" products LIVE as $4.25 SAMPLE products.
+- Each: title "Pattern Colorway" (original names), vendor Majilite, DWMJ-600001..600160,
+  variant DWMJ-600xxx-Sample @ $4.25 (Type=Sample), swatch image on Shopify CDN,
+  dwc.*/custom.* spec metafields, display_variant + full tags.
+- **Published to Online Store + Google & YouTube** channels → customer-visible on the storefront
+  (verified: /products/<handle>.json returns the product; count.json = 160 exactly).
+- 8 duplicate products (from interrupted runs) were deleted; 0 missing, 0 without image, 0 errors.
 
-## SAFE state
-- All created products are ACTIVE but NOT published to the Online Store or Google sales channels
-  -> NOT customer-visible on the storefront yet (storefront /products/<handle>.json returns 404). No GMC exposure.
+## Token
+- New OAuth access token for app "81026 API" minted (scopes write_products+write_publications,
+  which include reads) and stored in secrets-manager/.env as SHOPIFY_ADMIN_TOKEN (…7d19).
 
-## Blocked — needs merchant approval on the Shopify admin token
-The SHOPIFY_ADMIN_TOKEN has write_products but is MISSING merchant approval for **read_products** and
-**read_publications** ("[API] This action requires merchant approval for read_products scope").
-This blocks three finish steps:
-1. Verify all 160 exist + are correct (can't read/list/count).
-2. Reconcile the **7 handle-collision SKUs** (likely already created by an interrupted earlier run, handle taken,
-   but unverifiable/unrecorded): DWMJ-600042, -600048, -600062, -600065, -600088, -600090, -600102.
-3. Publish all 160 to the Online Store (+ Google) sales channel to make them customer-facing/sellable.
-
-### Action for Steve
-Approve the read scopes for the custom app in Shopify admin (Apps -> the app -> grant read_products +
-read_publications + write_publications), then re-run:
-  node scripts/reconcile.js          # verify 160, record the 7
-  node scripts/publish_shopify.js --all   # fills any true gaps (idempotent)
-  # then publish all to Online Store + Google channels (script to add once read_publications is granted)
+## Remaining (later, not blocking)
+- By-the-yard retail: add a yard variant + real pricing once Majilite sends the cost list
+  (Kathryn Gabriel / Two Gabriels, kathryn@twogabriels.com). Until then they sell as $4.25 samples.
+- Metallic Specialties II (card II, ~160 more SKUs) — same pipeline, next block DWMJ-600161+.

← 1365149 auto-data-snapshot: 2026-08-10T10:58:17 (1 data files) — dat  ·  back to Majilite Onboard  ·  Add smart collection 'Majilite Metallic Specialties I' (160 2b36441 →