[object Object]

← back to Designer Wallcoverings

Launch latest 300: activate + tag 'Trending Wallpaper 2026' the 226 that pass a 2-variant quality gate

76b4904697aa6b3d8b4a0a63343a82258ce404aa · 2026-06-11 23:23:01 -0700 · SteveStudio2

launch-latest.js gates on 2 variants (roll+sample), roll price > sample, image, title → activated
72 DRAFT→ACTIVE + tagged 226. 74 gate-skipped: 73 DWAE-prefix Schumacher/Boråstapeter products
with NO cost in any catalog (unpriceable, look like dupes of the proper DWLK Schumacher SKUs) +
1 no-image — flagged, not launched. fix-roll-prices.js looks up cost by SKU (found 0 for the DWAE set).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 76b4904697aa6b3d8b4a0a63343a82258ce404aa
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu Jun 11 23:23:01 2026 -0700

    Launch latest 300: activate + tag 'Trending Wallpaper 2026' the 226 that pass a 2-variant quality gate
    
    launch-latest.js gates on 2 variants (roll+sample), roll price > sample, image, title → activated
    72 DRAFT→ACTIVE + tagged 226. 74 gate-skipped: 73 DWAE-prefix Schumacher/Boråstapeter products
    with NO cost in any catalog (unpriceable, look like dupes of the proper DWLK Schumacher SKUs) +
    1 no-image — flagged, not launched. fix-roll-prices.js looks up cost by SKU (found 0 for the DWAE set).
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 shopify/scripts/fix-roll-prices.js | 67 ++++++++++++++++++++++++++++++++++
 shopify/scripts/launch-latest.js   | 74 ++++++++++++++++++++++++++++++++++++++
 shopify/scripts/tag-latest.js      | 49 +++++++++++++++++++++++++
 3 files changed, 190 insertions(+)

diff --git a/shopify/scripts/fix-roll-prices.js b/shopify/scripts/fix-roll-prices.js
new file mode 100644
index 00000000..bbf94508
--- /dev/null
+++ b/shopify/scripts/fix-roll-prices.js
@@ -0,0 +1,67 @@
+#!/usr/bin/env node
+/**
+ * Fix the roll/single variant price on newest products whose roll variant is unpriced
+ * (<= sample $4.25). Looks up cost by the roll variant SKU in schumacher_catalog (and other
+ * vendor catalogs), sets roll price = round(cost/0.65/0.85, 2). Sample variant left at $4.25.
+ *   node fix-roll-prices.js 300            # DRY
+ *   node fix-roll-prices.js 300 --commit
+ */
+const https = require('https'); const fs = require('fs'); const os = require('os');
+const { execFileSync } = require('child_process');
+const N = parseInt(process.argv[2] || '300', 10);
+const COMMIT = process.argv.includes('--commit');
+const SAMPLE = 4.25;
+const TOKEN = (fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function psql(s) { return execFileSync('psql', ['-At', '-F', '\t', '-d', 'dw_unified', '-c', s], { encoding: 'utf8', maxBuffer: 1 << 28 }).trim(); }
+function gql(query, variables) {
+  return new Promise(res => { const data = JSON.stringify({ query, variables });
+    const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
+      r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { res(JSON.parse(d)); } catch { res({}); } }); });
+    req.on('error', () => res({})); req.write(data); req.end(); });
+}
+async function gqlRetry(q, v) { for (let a = 0; a < 6; a++) { const r = await gql(q, v); if (r.errors && JSON.stringify(r.errors).includes('THROTTLED')) { await sleep(2500 * (a + 1)); continue; } await sleep(300); return r; } return {}; }
+
+// cost lookup across the vendor catalogs by dw_sku (Schumacher uses `cost`; others vary)
+function costForSku(sku) {
+  const s = sku.replace(/-sample$/i, '').replace(/'/g, "''");
+  const q = `SELECT cost FROM schumacher_catalog WHERE dw_sku='${s}' AND cost>0
+             UNION ALL SELECT cost_price FROM kravet_catalog WHERE dw_sku='${s}' AND cost_price>0
+             UNION ALL SELECT cost FROM romo_catalog WHERE dw_sku='${s}' AND cost>0
+             UNION ALL SELECT your_cost FROM thibaut_catalog WHERE dw_sku='${s}' AND your_cost>0 LIMIT 1;`;
+  const r = psql(q); const v = parseFloat((r || '').split('\n')[0]); return isNaN(v) ? null : v;
+}
+
+(async () => {
+  if (!TOKEN) { console.error('no token'); process.exit(1); }
+  const Q = `query($after:String){products(first:60,sortKey:CREATED_AT,reverse:true,after:$after){pageInfo{hasNextPage endCursor} edges{node{id title variants(first:5){edges{node{id sku price}}}}}}}`;
+  const prods = []; let after = null;
+  while (prods.length < N) { const r = await gqlRetry(Q, { after }); const p = r?.data?.products; if (!p) break; for (const e of p.edges) if (prods.length < N) prods.push(e.node); if (!p.pageInfo.hasNextPage) break; after = p.pageInfo.endCursor; }
+
+  const targets = [];
+  for (const n of prods) {
+    const vs = (n.variants?.edges || []).map(e => e.node);
+    const roll = vs.find(v => !/-sample$/i.test(v.sku || '') && parseFloat(v.price) <= SAMPLE + 0.5);
+    if (!roll) continue;                       // already priced or no roll variant
+    const cost = roll.sku ? costForSku(roll.sku) : null;
+    const retail = cost ? Math.round((cost / 0.65 / 0.85) * 100) / 100 : null;
+    targets.push({ n, roll, cost, retail });
+  }
+  const fixable = targets.filter(t => t.retail && t.retail > SAMPLE);
+  const nocost = targets.filter(t => !t.retail);
+  console.log(`unpriced-roll products: ${targets.length} | fixable (cost found) ${fixable.length} | NO cost ${nocost.length}`);
+  fixable.slice(0, 6).forEach(t => console.log(`   ${t.roll.sku}  cost $${t.cost} → roll $${t.retail}  ${t.n.title.slice(0,40)}`));
+  nocost.slice(0, 6).forEach(t => console.log(`   ✗ no-cost ${t.roll.sku}  ${t.n.title.slice(0,40)}`));
+  if (!COMMIT) { console.log(`\nDRY — --commit sets roll price on ${fixable.length} (the ${nocost.length} no-cost stay unpriced, won't launch).`); return; }
+
+  const M = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$pid,variants:$variants){userErrors{field message}}}`;
+  let ok = 0, fail = 0;
+  for (const t of fixable) {
+    const r = await gqlRetry(M, { pid: t.n.id, variants: [{ id: t.roll.id, price: String(t.retail) }] });
+    const e = r?.data?.productVariantsBulkUpdate?.userErrors;
+    if (e && e.length) { fail++; console.log(`  ✗ ${t.roll.sku} ${JSON.stringify(e).slice(0,90)}`); }
+    else { ok++; if (ok % 20 === 0) process.stdout.write(`\r  priced ${ok}/${fixable.length}…`); }
+  }
+  console.log(`\nDONE: roll-priced ${ok}, failed ${fail}. ${nocost.length} still no-cost (excluded from launch).`);
+})();
diff --git a/shopify/scripts/launch-latest.js b/shopify/scripts/launch-latest.js
new file mode 100644
index 00000000..75886539
--- /dev/null
+++ b/shopify/scripts/launch-latest.js
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+/**
+ * Audit → (fix) → activate → tag the N newest Shopify products.
+ * Quality gate: each must have exactly 2 variants (roll + Sample) with non-empty SKU and a
+ * sane price (roll > sample $4.25), a featured image, and a non-empty title. Then status=ACTIVE
+ * + tag "Trending Wallpaper 2026".
+ *   node launch-latest.js 300                 # AUDIT only (default) — report quality
+ *   node launch-latest.js 300 --commit        # activate + tag the ones that PASS the gate
+ */
+const https = require('https'); const fs = require('fs'); const os = require('os');
+const N = parseInt(process.argv[2] || '300', 10);
+const COMMIT = process.argv.includes('--commit');
+const TAG = 'Trending Wallpaper 2026';
+const SAMPLE = 4.25;
+const TOKEN = (fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function gql(query, variables) {
+  return new Promise(res => { const data = JSON.stringify({ query, variables });
+    const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
+      r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { res(JSON.parse(d)); } catch { res({}); } }); });
+    req.on('error', () => res({})); req.write(data); req.end(); });
+}
+async function gqlRetry(q, v) { for (let a = 0; a < 6; a++) { const r = await gql(q, v); if (r.errors && JSON.stringify(r.errors).includes('THROTTLED')) { await sleep(2500 * (a + 1)); continue; } await sleep(300); return r; } return {}; }
+
+function audit(n) {
+  const vs = (n.variants?.edges || []).map(e => e.node);
+  const prices = vs.map(v => parseFloat(v.price)).filter(x => !isNaN(x));
+  const issues = [];
+  if (vs.length !== 2) issues.push(`variants=${vs.length}`);
+  if (vs.some(v => !v.sku || !v.sku.trim())) issues.push('empty-sku');
+  const hasSample = vs.some(v => Math.abs(parseFloat(v.price) - SAMPLE) < 0.01 || /-sample$/i.test(v.sku || ''));
+  const hasRoll = vs.some(v => parseFloat(v.price) > SAMPLE + 0.5);
+  if (!hasSample) issues.push('no-sample-variant');
+  if (!hasRoll) issues.push('no-roll-price');
+  if (!n.featuredImage?.url) issues.push('no-image');
+  if (!n.title || !n.title.trim()) issues.push('no-title');
+  if (Math.max(0, ...prices) <= SAMPLE) issues.push('price<=sample');
+  return issues;
+}
+
+(async () => {
+  if (!TOKEN) { console.error('no token'); process.exit(1); }
+  const Q = `query($after:String){products(first:60,sortKey:CREATED_AT,reverse:true,after:$after){pageInfo{hasNextPage endCursor} edges{node{id title status createdAt productType featuredImage{url} tags variants(first:5){edges{node{sku price}}}}}}}`;
+  const prods = []; let after = null;
+  while (prods.length < N) {
+    const r = await gqlRetry(Q, { after });
+    const p = r?.data?.products; if (!p) { console.error('fetch fail', JSON.stringify(r).slice(0,200)); break; }
+    for (const e of p.edges) if (prods.length < N) prods.push(e.node);
+    if (!p.pageInfo.hasNextPage) break; after = p.pageInfo.endCursor;
+  }
+  const pass = [], failed = [];
+  for (const n of prods) { const iss = audit(n); (iss.length ? failed : pass).push({ n, iss }); }
+  console.log(`audited newest ${prods.length} | PASS gate ${pass.length} | FAIL gate ${failed.length}`);
+  const byIssue = {}; failed.forEach(f => f.iss.forEach(i => byIssue[i] = (byIssue[i]||0)+1));
+  console.log('  fail reasons:', JSON.stringify(byIssue));
+  failed.slice(0, 12).forEach(f => console.log(`   ✗ [${f.iss.join(',')}] ${f.n.status} ${f.n.title.slice(0,44)}`));
+  const active = prods.filter(n => n.status === 'ACTIVE').length;
+  console.log(`  currently: ACTIVE ${active} / DRAFT ${prods.length - active}`);
+
+  if (!COMMIT) { console.log(`\nAUDIT only. --commit would: activate + tag "${TAG}" the ${pass.length} that PASS (skip the ${failed.length} that fail the gate).`); return; }
+
+  const mUpd = `mutation($input:ProductInput!){productUpdate(input:$input){product{id status} userErrors{field message}}}`;
+  const mTag = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+  let act = 0, tagged = 0, fail = 0;
+  for (const { n } of pass) {
+    let err = null;
+    if (n.status !== 'ACTIVE') { const r = await gqlRetry(mUpd, { input: { id: n.id, status: 'ACTIVE' } }); const e = r?.data?.productUpdate?.userErrors; if (e && e.length) err = e; else act++; }
+    if (!(n.tags || []).includes(TAG)) { const r = await gqlRetry(mTag, { id: n.id, tags: [TAG] }); const e = r?.data?.tagsAdd?.userErrors; if (e && e.length) err = err || e; else tagged++; }
+    if (err) { fail++; console.log(`  ✗ ${n.id} ${JSON.stringify(err).slice(0,90)}`); }
+    if ((act + tagged) % 40 === 0) process.stdout.write(`\r  activated ${act} / tagged ${tagged}…`);
+  }
+  console.log(`\nDONE: activated ${act}, tagged ${tagged} (gate-passed ${pass.length}), failed ${fail}. Gate-skipped ${failed.length} need fixing first.`);
+})();
diff --git a/shopify/scripts/tag-latest.js b/shopify/scripts/tag-latest.js
new file mode 100644
index 00000000..190ee98e
--- /dev/null
+++ b/shopify/scripts/tag-latest.js
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+/**
+ * Add a tag to the N most-recently-created Shopify products (additive, reversible).
+ *   node tag-latest.js "Trending Wallpaper 2026" 300            # DRY (default)
+ *   node tag-latest.js "Trending Wallpaper 2026" 300 --commit
+ */
+const https = require('https'); const fs = require('fs'); const os = require('os');
+const TAG = process.argv[2];
+const N = parseInt(process.argv[3] || '300', 10);
+const COMMIT = process.argv.includes('--commit');
+if (!TAG) { console.error('usage: tag-latest.js "<tag>" <N> [--commit]'); process.exit(1); }
+const TOKEN = (fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function gql(query, variables) {
+  return new Promise(res => { const data = JSON.stringify({ query, variables });
+    const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
+      r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { res(JSON.parse(d)); } catch { res({}); } }); });
+    req.on('error', () => res({})); req.write(data); req.end(); });
+}
+async function gqlRetry(q, v) { for (let a = 0; a < 6; a++) { const r = await gql(q, v); if (r.errors && JSON.stringify(r.errors).includes('THROTTLED')) { await sleep(2500 * (a + 1)); continue; } await sleep(300); return r; } return {}; }
+
+(async () => {
+  if (!TOKEN) { console.error('no token'); process.exit(1); }
+  // newest-first by created date
+  const Q = `query($after:String){products(first:100,sortKey:CREATED_AT,reverse:true,after:$after){pageInfo{hasNextPage endCursor} edges{node{id title createdAt status tags}}}}`;
+  const prods = []; let after = null;
+  while (prods.length < N) {
+    const r = await gqlRetry(Q, { after });
+    const p = r?.data?.products; if (!p) { console.error('fetch fail', JSON.stringify(r).slice(0,200)); break; }
+    for (const e of p.edges) { if (prods.length < N) prods.push(e.node); }
+    if (!p.pageInfo.hasNextPage) break; after = p.pageInfo.endCursor;
+  }
+  const already = prods.filter(x => (x.tags || []).includes(TAG)).length;
+  const todo = prods.filter(x => !(x.tags || []).includes(TAG));
+  console.log(`newest ${prods.length} products | already tagged ${already} | to tag ${todo.length}`);
+  console.log(`  range: ${prods[prods.length-1]?.createdAt?.slice(0,10)} … ${prods[0]?.createdAt?.slice(0,10)}`);
+  prods.slice(0,3).forEach(x => console.log(`   e.g. ${x.createdAt.slice(0,10)} ${x.status} ${x.title.slice(0,46)}`));
+  if (!COMMIT) { console.log(`\nDRY — re-run with --commit to add "${TAG}" to ${todo.length} products.`); return; }
+  const M = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+  let ok = 0, fail = 0;
+  for (const x of todo) {
+    const r = await gqlRetry(M, { id: x.id, tags: [TAG] });
+    const ue = r?.data?.tagsAdd?.userErrors;
+    if (ue && ue.length) { fail++; console.log(`  ✗ ${x.id} ${JSON.stringify(ue).slice(0,90)}`); }
+    else { ok++; if (ok % 25 === 0) process.stdout.write(`\r  tagged ${ok}/${todo.length}…`); }
+  }
+  console.log(`\nDONE: tagged ${ok}, failed ${fail} with "${TAG}".`);
+})();

← 4b00794e Monitor uses 2026 authoritative pricing (kravet_authoritativ  ·  back to Designer Wallcoverings  ·  DWAE: price 166 from Schumacher net-cost API + activate + ta 0d278a72 →