[object Object]

← back to Degournay Review Viewer

Add marquee drag-select + Confirm Settlement Violation (archive+unpublish, never online)

f3f459ff33befe7b044216d175c576f49c0ada35 · 2026-07-22 08:06:07 -0700 · Steve Abrams

Files touched

Diff

commit f3f459ff33befe7b044216d175c576f49c0ada35
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 08:06:07 2026 -0700

    Add marquee drag-select + Confirm Settlement Violation (archive+unpublish, never online)
---
 server.js | 156 +++++++++++++++++++++++++++++++++++++++++---------------------
 1 file changed, 103 insertions(+), 53 deletions(-)

diff --git a/server.js b/server.js
index af04192..5ce2ad3 100644
--- a/server.js
+++ b/server.js
@@ -1,10 +1,12 @@
 #!/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.
+ * Grid of the needs_review De Gournay SKUs. Steve selects (click, drag-marquee,
+ * or select-all) then either:
+ *   - ACTIVATE SELECTED  → Google-channel EXCLUDED → status ACTIVE → Online Store (goes live)
+ *   - CONFIRM SETTLEMENT VIOLATION → status ARCHIVED + unpublished from every channel
+ *       → never shows online; recorded to violations log + dw_unified.
+ * Basic auth admin/DW2024!. Live Shopify writes happen ONLY on Steve's explicit click.
  */
 const http = require('http');
 const { execSync } = require('child_process');
@@ -18,6 +20,7 @@ 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';
+const VIOLOG = process.env.HOME + '/.claude/yolo-queue/degournay-settlement-violations.jsonl';
 
 function q(sql) { return execSync(`psql "${PG}" -tAc "${sql.replace(/"/g, '\\"')}"`).toString(); }
 async function gql(query, variables) {
@@ -29,7 +32,7 @@ async function gql(query, variables) {
 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`;
+  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, settlement_status from degournay_catalog where settlement_status in ('needs_review','violation') and shopify_product_id is not null) t`;
   return JSON.parse(q(sql) || '[]');
 }
 
@@ -40,68 +43,85 @@ async function activate(pid) {
   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;
+  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 };
+  return { pid, ok, status: p.status };
+}
+
+async function violate(pid) {
+  const gid = `gid://shopify/Product/${pid}`;
+  // unpublish from every channel, then ARCHIVE so it can never show online
+  await gql(`mutation($id:ID!,$g:ID!,$o:ID!){publishableUnpublish(id:$id,input:[{publicationId:$g},{publicationId:$o}]){userErrors{message}}}`, { id: gid, g: GOOGLE, o: ONLINE });
+  const u = await gql(`mutation($p:ProductInput!){productUpdate(input:$p){product{status onO:publishedOnPublication(publicationId:"${ONLINE}")} userErrors{message}}}`, { p: { id: gid, status: 'ARCHIVED' } });
+  if (u.productUpdate.userErrors.length) throw new Error(JSON.stringify(u.productUpdate.userErrors));
+  const p = u.productUpdate.product; const ok = p.status === 'ARCHIVED' && !p.onO;
+  if (ok) {
+    execSync(`psql "${PG}" -q -c "UPDATE degournay_catalog SET settlement_status='violation', activated_at=NULL, updated_at=now() WHERE shopify_product_id='${pid}';"`);
+    const row = q(`select json_build_object('pid','${pid}','pattern',pattern_name,'color',color_name,'notes',settlement_notes,'sku',degournay_sku) from degournay_catalog where shopify_product_id='${pid}'`).trim();
+    try { fs.appendFileSync(VIOLOG, JSON.stringify({ ...JSON.parse(row), confirmed_at: new Date().toISOString() }) + '\n'); } catch {}
+  }
+  return { pid, ok, status: p.status };
 }
 
 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>
+<title>De Gournay — Settlement Review</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}
+*{box-sizing:border-box}body{margin:0;font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;background:#0e0e10;color:#e8e6e1;-webkit-user-select:none;user-select:none}
+header{position:sticky;top:0;z-index:20;background:#16161a;border-bottom:1px solid #2a2a30;padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+h1{font-size:15px;margin:0;font-weight:600}
 .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}
+button.go{background:#2f9e5a;border-color:#2f9e5a;color:#fff;font-weight:600}
+button.bad{background:#c23b3b;border-color:#c23b3b;color:#fff;font-weight:700}
+button:disabled{opacity:.4;cursor:default}
+#grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--min),1fr));gap:12px;padding:16px;position:relative}
+.card{background:#17171c;border:1px solid #2a2a30;border-radius:10px;overflow:hidden;position:relative;transition:border-color .1s}
+.card.sel{border-color:#e0b23a;box-shadow:0 0 0 2px #e0b23a}
+.card.done{opacity:.5}.card.viol{opacity:.5}
+.card .imgwrap{aspect-ratio:3/4;background:#0b0b0d;position:relative;cursor:zoom-in}
+.card img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none}
+.card .chk{position:absolute;top:8px;left:8px;width:22px;height:22px;accent-color:#e0b23a;cursor:pointer;z-index:2}
+.card .badge{position:absolute;top:8px;right:8px;background:#000b;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:600}
+.badge.ok{color:#57c07a}.badge.viol{color:#ff8a8a}
 .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 .ttl{font-weight:600;font-size:13px}.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}
+#marquee{position:fixed;border:1.5px solid #e0b23a;background:#e0b23a22;pointer-events:none;z-index:15;display:none}
+.hint{font-size:12px;color:#7f7f88}
 </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>
+  <label class="muted">Grid <input type="range" id="dens" min="140" max="380" value="230"></label>
+  <span class="hint">drag on the grid to marquee-select</span>
   <span style="flex:1"></span>
   <span id="selcnt" class="muted">0 selected</span>
-  <button id="act" class="primary" disabled>Activate Selected</button>
+  <button id="act" class="go" disabled>Activate Selected</button>
+  <button id="viol" class="bad" disabled>Confirm Settlement Violation</button>
 </header>
 <div id="grid"></div>
+<div id="marquee"></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 pending(p){return !p.activated_at && p.settlement_status!=='violation';}
 function render(){
-  const s=$('#sort').value;
-  let list=[...items];
+  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>':'')+
+    const done=!!p.activated_at, viol=p.settlement_status==='viol'||p.settlement_status==='violation', on=sel.has(p.pid), locked=done||viol;
+    return '<div class="card'+(on?' sel':'')+(done?' done':'')+(viol?' viol':'')+'" data-id="'+p.pid+'">'+
+      '<div class="imgwrap" data-open="'+p.product_url+'">'+
+      (locked?'':'<input type="checkbox" class="chk" '+(on?'checked':'')+' data-id="'+p.pid+'">')+
+      (done?'<span class="badge ok">✓ live</span>':'')+(viol?'<span class="badge viol">⛔ violation</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>'+
@@ -109,41 +129,71 @@ function render(){
       '<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();};
+function syncSel(){$('#selcnt').textContent=sel.size+' selected';$('#act').disabled=$('#viol').disabled=sel.size===0;$('#act').textContent='Activate Selected ('+sel.size+')';$('#viol').textContent='Confirm Settlement Violation ('+sel.size+')';render();}
+$('#all').onclick=()=>{items.filter(pending).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();
+// click: checkbox toggle, or open image
+$('#grid').addEventListener('click',e=>{
+  if(dragMoved){dragMoved=false;return;}
+  const chk=e.target.closest('.chk'); if(chk){const id=chk.dataset.id;if(sel.has(id))sel.delete(id);else sel.add(id);syncSel();return;}
+  const img=e.target.closest('.imgwrap'); if(img&&img.dataset.open)window.open(img.dataset.open,'_blank');
+});
+// marquee drag-select
+let sx,sy,dragging=false,dragMoved=false;
+const mq=$('#marquee');
+document.addEventListener('mousedown',e=>{
+  if(e.button!==0)return; if(e.target.closest('header'))return; if(e.target.closest('.chk'))return;
+  sx=e.clientX;sy=e.clientY;dragging=true;dragMoved=false;
+});
+document.addEventListener('mousemove',e=>{
+  if(!dragging)return;
+  if(Math.abs(e.clientX-sx)+Math.abs(e.clientY-sy)>6)dragMoved=true;
+  if(!dragMoved)return;
+  const x=Math.min(sx,e.clientX),y=Math.min(sy,e.clientY),w=Math.abs(e.clientX-sx),h=Math.abs(e.clientY-sy);
+  mq.style.display='block';mq.style.left=x+'px';mq.style.top=y+'px';mq.style.width=w+'px';mq.style.height=h+'px';
+  const r={left:x,top:y,right:x+w,bottom:y+h};
+  document.querySelectorAll('.card').forEach(c=>{
+    const id=c.dataset.id, b=c.getBoundingClientRect();
+    const hit=!(b.right<r.left||b.left>r.right||b.bottom<r.top||b.top>r.bottom);
+    if(hit && !c.classList.contains('done') && !c.classList.contains('viol')) sel.add(id);
+  });
+  syncSel();
+});
+document.addEventListener('mouseup',()=>{dragging=false;mq.style.display='none';});
+async function run(url,verb){
+  const ids=[...sel]; const label=verb==='violation'?'CONFIRM SETTLEMENT VIOLATION on':'Activate';
+  const warn=verb==='violation'?'These will be ARCHIVED + unpublished from every channel and will NEVER show online.':'These go live: Google-channel EXCLUDED → ACTIVE → Online Store.';
+  if(!confirm(label+' '+ids.length+' product(s)?\\n'+warn))return;
+  $('#act').disabled=$('#viol').disabled=true;$(verb==='violation'?'#viol':'#act').textContent='Working…';
+  const j=await (await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids})})).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.'));
+  alert((verb==='violation'?'Marked violation ':'Activated ')+ok+'/'+ids.length+(bad.length?'\\nFailed: '+bad.map(b=>b.pid).join(', '):''));
   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();}
+}
+$('#act').onclick=()=>run('/api/activate','activate');
+$('#viol').onclick=()=>run('/api/violation','violation');
+async function load(){items=await (await fetch('/api/products')).json();const pend=items.filter(pending).length;$('#cnt').textContent='('+pend+' 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 === '/') { 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') {
+    if ((req.url === '/api/activate' || req.url === '/api/violation') && 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 fn = req.url === '/api/violation' ? violate : activate;
       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`);
+      for (const id of ids) { try { results.push(await fn(id)); } catch (e) { results.push({ pid: id, ok: false, err: String(e).slice(0, 120) }); } await sleep(400); }
+      console.log(`[${req.url}] ${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));
+server.listen(49479, '127.0.0.1', () => console.log('DG_REVIEW_VIEWER on 49479'));

← a9e408a De Gournay settlement-review viewer (select 1-all, activate  ·  back to Degournay Review Viewer  ·  chore: lint (sanitize incoming ids) + refactor (drop dead vi df1320d →