← back to Degournay Review Viewer

server.js

200 lines

#!/usr/bin/env node
/**
 * De Gournay settlement-review viewer.
 * 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');
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';
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) {
  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, 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) || '[]');
}

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 };
}

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</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;-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.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}.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}
#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">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="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];
  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, 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>'+
      '<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 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');}
// 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((verb==='violation'?'Marked violation ':'Activated ')+ok+'/'+ids.length+(bad.length?'\\nFailed: '+bad.map(b=>b.pid).join(', '):''));
  sel.clear();await load();syncSel();
}
$('#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 === '/api/products') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(products())); }
    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).filter(id => /^\d+$/.test(String(id)));
      const fn = req.url === '/api/violation' ? violate : activate;
      const results = [];
      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(49479, '127.0.0.1', () => console.log('DG_REVIEW_VIEWER on 49479'));