[object Object]

← back to Degournay Review Viewer

De Gournay settlement-review viewer (select 1-all, activate with Google-feed exclusion)

a9e408a5c71c522af26128dd399905cb423c7cf8 · 2026-07-22 07:56:18 -0700 · Steve Abrams

Files touched

Diff

commit a9e408a5c71c522af26128dd399905cb423c7cf8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 07:56:18 2026 -0700

    De Gournay settlement-review viewer (select 1-all, activate with Google-feed exclusion)
---
 .gitignore |   4 ++
 server.js  | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 153 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1bcdbdc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.env*
+*.log
+.DS_Store
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..af04192
--- /dev/null
+++ b/server.js
@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+/**
+ * De Gournay settlement-review viewer.
+ * Shows the 179 needs_review De Gournay SKUs for Steve to select 1→all and
+ * ACTIVATE (Google-feed excluded first → status=ACTIVE → Online Store).
+ * Basic auth admin/DW2024!. Reads live from dw_unified; writes activated_at back.
+ * Live Shopify writes happen ONLY on Steve's explicit "Activate Selected" click.
+ */
+const http = require('http');
+const { execSync } = require('child_process');
+const fs = require('fs');
+
+const ENV = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const GQL = `https://${DOMAIN}/admin/api/${API}/graphql.json`;
+const GOOGLE = 'gid://shopify/Publication/29646651457';
+const ONLINE = 'gid://shopify/Publication/22208643184';
+const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
+const PG = 'host=/tmp dbname=dw_unified';
+
+function q(sql) { return execSync(`psql "${PG}" -tAc "${sql.replace(/"/g, '\\"')}"`).toString(); }
+async function gql(query, variables) {
+  const res = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
+  const j = await res.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors));
+  return j.data;
+}
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function products() {
+  const sql = `select coalesce(json_agg(t order by t.pattern_name, t.color_name),'[]') from (select shopify_product_id pid, pattern_name, color_name, thumb_url, image_url, product_url, settlement_notes, ai_colors, crawled_at, activated_at from degournay_catalog where settlement_status='needs_review' and shopify_product_id is not null) t`;
+  return JSON.parse(q(sql) || '[]');
+}
+
+async function activate(pid) {
+  const gid = `gid://shopify/Product/${pid}`;
+  await gql(`mutation($id:ID!,$p:ID!){publishableUnpublish(id:$id,input:[{publicationId:$p}]){userErrors{message}}}`, { id: gid, p: GOOGLE });
+  const u = await gql(`mutation($p:ProductInput!){productUpdate(input:$p){product{status} userErrors{message}}}`, { p: { id: gid, status: 'ACTIVE' } });
+  if (u.productUpdate.userErrors.length) throw new Error(JSON.stringify(u.productUpdate.userErrors));
+  await gql(`mutation($id:ID!,$p:ID!){publishablePublish(id:$id,input:[{publicationId:$p}]){userErrors{message}}}`, { id: gid, p: ONLINE });
+  const v = await gql(`query($id:ID!,$g:ID!,$o:ID!){product(id:$id){status onG:publishedOnPublication(publicationId:$g) onO:publishedOnPublication(publicationId:$o)}}`, { id: gid, g: GOOGLE, o: ONLINE });
+  const p = v.product;
+  const ok = p.status === 'ACTIVE' && !p.onG && p.onO;
+  if (ok) execSync(`psql "${PG}" -q -c "UPDATE degournay_catalog SET activated_at=now(), settlement_status='ok' WHERE shopify_product_id='${pid}';"`);
+  return { pid, ok, status: p.status, onGoogle: p.onG, onOnlineStore: p.onO };
+}
+
+const PAGE = () => `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>De Gournay — Settlement Review (179)</title>
+<style>
+:root{--min:230px}
+*{box-sizing:border-box}body{margin:0;font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;background:#0e0e10;color:#e8e6e1}
+header{position:sticky;top:0;z-index:10;background:#16161a;border-bottom:1px solid #2a2a30;padding:10px 16px;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
+h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.02em}
+.muted{color:#8a8a92}
+button,select{background:#22222a;color:#e8e6e1;border:1px solid #34343c;border-radius:7px;padding:7px 11px;font-size:13px;cursor:pointer}
+button.primary{background:#3a6df0;border-color:#3a6df0;color:#fff;font-weight:600}
+button.primary:disabled{opacity:.4;cursor:default}
+input[type=range]{vertical-align:middle}
+#grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--min),1fr));gap:12px;padding:16px}
+.card{background:#17171c;border:1px solid #2a2a30;border-radius:10px;overflow:hidden;position:relative;transition:border-color .12s}
+.card.sel{border-color:#3a6df0;box-shadow:0 0 0 1px #3a6df0}
+.card.done{opacity:.55}
+.card .imgwrap{aspect-ratio:3/4;background:#0b0b0d;cursor:pointer;position:relative}
+.card img{width:100%;height:100%;object-fit:cover;display:block}
+.card .chk{position:absolute;top:8px;left:8px;width:22px;height:22px;accent-color:#3a6df0;cursor:pointer}
+.card .badge{position:absolute;top:8px;right:8px;background:#000a;padding:2px 7px;border-radius:20px;font-size:11px}
+.card .body{padding:9px 10px}
+.card .ttl{font-weight:600;font-size:13px;margin-bottom:2px}
+.card .cw{color:#a9a7a0;font-size:12px;margin-bottom:5px}
+.card .note{font-size:11px;color:#c9a86a;margin-bottom:6px;min-height:28px}
+.dots{display:flex;gap:3px;margin-bottom:6px}.dot{width:14px;height:14px;border-radius:50%;border:1px solid #0006}
+.when{font-size:10.5px;color:#6f6f77}
+a{color:#7fa0ff;text-decoration:none}
+.ok{color:#57c07a}.err{color:#e06060}
+</style></head><body>
+<header>
+  <h1>De Gournay · Settlement Review <span class="muted" id="cnt"></span></h1>
+  <button id="all">Select all</button><button id="none">Clear</button>
+  <label class="muted">Sort <select id="sort"><option value="design">Design A→Z</option><option value="color">Colourway A→Z</option><option value="bird">Birds/Butterflies first</option></select></label>
+  <label class="muted">Density <input type="range" id="dens" min="150" max="360" value="230"></label>
+  <span style="flex:1"></span>
+  <span id="selcnt" class="muted">0 selected</span>
+  <button id="act" class="primary" disabled>Activate Selected</button>
+</header>
+<div id="grid"></div>
+<script>
+let items=[], sel=new Set();
+const $=s=>document.querySelector(s);
+function palette(ai){try{const a=typeof ai==='string'?JSON.parse(ai):ai;return (a||[]).slice(0,6).map(c=>'<span class="dot" title="'+c.name+' '+c.pct+'%" style="background:'+c.hex+'"></span>').join('')}catch{return''}}
+function fmt(t){try{return new Date(t).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch{return t}}
+function render(){
+  const s=$('#sort').value;
+  let list=[...items];
+  if(s==='color')list.sort((a,b)=>(a.color_name||'').localeCompare(b.color_name||''));
+  else if(s==='bird')list.sort((a,b)=>(/B=1/.test(b.settlement_notes)?1:0)-(/B=1/.test(a.settlement_notes)?1:0));
+  else list.sort((a,b)=>(a.pattern_name||'').localeCompare(b.pattern_name||'')||(a.color_name||'').localeCompare(b.color_name||''));
+  $('#grid').innerHTML=list.map(p=>{
+    const done=!!p.activated_at; const on=sel.has(p.pid);
+    return '<div class="card'+(on?' sel':'')+(done?' done':'')+'" data-id="'+p.pid+'">'+
+      '<div class="imgwrap" onclick="window.open(\\''+p.product_url+'\\')">'+
+      (done?'':'<input type="checkbox" class="chk" '+(on?'checked':'')+' onclick="event.stopPropagation();tog(\\''+p.pid+'\\')">')+
+      (done?'<span class="badge ok">✓ live</span>':'')+
+      '<img loading="lazy" src="'+(p.thumb_url||p.image_url||'')+'"></div>'+
+      '<div class="body"><div class="ttl">'+p.pattern_name+'</div><div class="cw">'+(p.color_name||'')+'</div>'+
+      '<div class="note" title="'+(p.settlement_notes||'')+'">'+(p.settlement_notes||'').split('|')[0]+'</div>'+
+      '<div class="dots">'+palette(p.ai_colors)+'</div>'+
+      '<div class="when" title="crawled '+p.crawled_at+'">🕓 '+fmt(p.crawled_at)+'</div></div></div>';
+  }).join('');
+}
+function tog(id){if(sel.has(id))sel.delete(id);else sel.add(id);syncSel();}
+function syncSel(){$('#selcnt').textContent=sel.size+' selected';$('#act').disabled=sel.size===0;$('#act').textContent='Activate Selected ('+sel.size+')';render();}
+$('#all').onclick=()=>{items.filter(p=>!p.activated_at).forEach(p=>sel.add(p.pid));syncSel();};
+$('#none').onclick=()=>{sel.clear();syncSel();};
+$('#sort').onchange=render;
+$('#dens').oninput=e=>{document.documentElement.style.setProperty('--min',e.target.value+'px');localStorage.dgDens=e.target.value;};
+if(localStorage.dgDens){$('#dens').value=localStorage.dgDens;document.documentElement.style.setProperty('--min',localStorage.dgDens+'px');}
+$('#act').onclick=async()=>{
+  const ids=[...sel];
+  if(!confirm('Activate '+ids.length+' De Gournay product(s)?\\nEach goes: Google-channel EXCLUDED → status ACTIVE → Online Store. This is customer-facing live.'))return;
+  $('#act').disabled=true;$('#act').textContent='Activating…';
+  const r=await fetch('/api/activate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids})});
+  const j=await r.json();
+  const ok=j.results.filter(x=>x.ok).length,bad=j.results.filter(x=>!x.ok);
+  alert('Activated '+ok+'/'+ids.length+(bad.length?'\\nFailed: '+bad.map(b=>b.pid).join(', '):'\\nAll on Online Store, none on Google.'));
+  sel.clear();await load();syncSel();
+};
+async function load(){items=await (await fetch('/api/products')).json();$('#cnt').textContent='('+items.filter(p=>!p.activated_at).length+' pending · '+items.length+' total)';render();}
+load();
+</script></body></html>`;
+
+const server = http.createServer(async (req, res) => {
+  if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="dg"' }); return res.end('auth'); }
+  try {
+    if (req.url === '/' ) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE()); }
+    if (req.url === '/api/products') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(products())); }
+    if (req.url === '/api/activate' && req.method === 'POST') {
+      let b = ''; req.on('data', c => b += c); await new Promise(r => req.on('end', r));
+      const ids = (JSON.parse(b || '{}').ids || []).slice(0, 200);
+      const results = [];
+      for (const id of ids) { try { results.push(await activate(id)); } catch (e) { results.push({ pid: id, ok: false, err: String(e).slice(0, 120) }); } await sleep(400); }
+      console.log(`[activate] ${results.filter(r => r.ok).length}/${ids.length} ok`);
+      res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ results }));
+    }
+    res.writeHead(404); res.end('nf');
+  } catch (e) { res.writeHead(500); res.end(String(e).slice(0, 200)); }
+});
+server.listen(0, '127.0.0.1', () => console.log('DG_REVIEW_VIEWER_PORT=' + server.address().port));

(oldest)  ·  back to Degournay Review Viewer  ·  Add marquee drag-select + Confirm Settlement Violation (arch f3f459f →