← back to New Import Viewer
Show full Shopify state (new/published/unpublished/archived) + staged action suite under collapsed per-card chips; indexed shopify_products join
d24c5abde1208944ccc7918f4bc8c5baf363b8ea · 2026-06-09 18:01:09 -0700 · SteveStudio2
Files touched
M data/launch-queue.jsonlM public/index.htmlM server.js
Diff
commit d24c5abde1208944ccc7918f4bc8c5baf363b8ea
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue Jun 9 18:01:09 2026 -0700
Show full Shopify state (new/published/unpublished/archived) + staged action suite under collapsed per-card chips; indexed shopify_products join
---
data/launch-queue.jsonl | 1 +
public/index.html | 201 ++++++++++++++++++++++++++++++++++--------------
server.js | 173 ++++++++++++++++++++++++++++-------------
3 files changed, 265 insertions(+), 110 deletions(-)
diff --git a/data/launch-queue.jsonl b/data/launch-queue.jsonl
index e69de29..d49d82e 100644
--- a/data/launch-queue.jsonl
+++ b/data/launch-queue.jsonl
@@ -0,0 +1 @@
+{"batchId":"unpublish-1-67187-mq7d4mkl","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:01:04.965Z"}
diff --git a/public/index.html b/public/index.html
index b472945..c6123e9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,8 +1,9 @@
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Products New to Import · dw_unified</title>
+<title>Catalog · Shopify state · dw_unified</title>
<style>
-:root{--cols:6;--bg:#0c0d12;--panel:#15171f;--card:#181b24;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--new:#f59e0b;--line:#262a36}
+:root{--cols:6;--bg:#0c0d12;--panel:#15171f;--card:#181b24;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
+ --new:#f59e0b;--pub:#34d399;--unpub:#94a3b8;--arch:#f87171;--del:#6b7280;--ship:#60a5fa}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
@@ -14,54 +15,83 @@ select,input[type=text]{background:var(--panel);color:var(--ink);border:1px soli
input[type=text]{min-width:180px}
.ctl{display:flex;align-items:center;gap:7px}
input[type=range]{accent-color:var(--acc)}
+.warn{font-size:11px;color:#1a1205;background:var(--new);border-radius:6px;padding:2px 9px;font-weight:700}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;padding:16px}
-.card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
+.card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative}
+.card.sel{outline:2px solid var(--acc);outline-offset:-2px}
.imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
.imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center 12%;display:block}
.imgwrap.noimg::after{content:'no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#3a4150;font-size:11px;letter-spacing:.5px}
-.card .b{padding:9px 10px;display:flex;flex-direction:column;gap:4px}
+.card .b{padding:9px 10px;display:flex;flex-direction:column;gap:6px}
.vend{font-size:11px;color:var(--acc);font-weight:700;text-transform:uppercase;letter-spacing:.4px}
.ttl{font-weight:600;line-height:1.25}
.sub{color:var(--mut);font-size:12px}
-.when{color:var(--mut);font-size:11px;display:flex;align-items:center;gap:4px;margin-top:2px}
-.row{display:flex;justify-content:space-between;align-items:center;gap:6px}
.sku{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut)}
-a.open{color:var(--acc);text-decoration:none;font-size:11px;border:1px solid var(--line);border-radius:6px;padding:2px 7px}
-a.open:hover{background:var(--panel)}
-#more{display:block;margin:8px auto 40px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
-#empty{padding:60px;text-align:center;color:var(--mut)}
-.card{position:relative}
-.card.sel{outline:2px solid var(--acc);outline-offset:-2px}
.pick{position:absolute;top:7px;left:7px;z-index:3;background:#000a;border-radius:6px;padding:3px 4px;display:flex;cursor:pointer}
.pick input{width:17px;height:17px;accent-color:var(--acc);cursor:pointer;margin:0}
+/* ── status chip (always visible) + chip row ── */
+.chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
+.chip{font-size:10.5px;font-weight:700;border-radius:999px;padding:2px 9px;letter-spacing:.3px;white-space:nowrap;border:1px solid transparent}
+.chip.st-NEW{background:var(--new);color:#1a1205}
+.chip.st-PUBLISHED{background:var(--pub);color:#04211a}
+.chip.st-UNPUBLISHED{background:var(--unpub);color:#11151c}
+.chip.st-ARCHIVED{background:var(--arch);color:#2a0606}
+.chip.st-DELETED{background:var(--del);color:#0b0d11}
+.chip.st-ON_SHOPIFY{background:var(--ship);color:#04122a}
+.chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
+.chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
+/* ── collapsed detail drawer ── */
+.details{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);margin-top:2px;padding-top:8px}
+.card.open .details{display:flex}
+.kv{display:flex;justify-content:space-between;gap:8px;font-size:11.5px}
+.kv .k{color:var(--mut)}
+.kv .v{text-align:right;word-break:break-word}
+.lnk{color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px}
+.lnk:hover{background:var(--panel)}
+.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
+.act{font-size:11px;font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer}
+.act:hover{border-color:var(--acc)}
+.act.pub{border-color:var(--pub);color:var(--pub)}
+.act.unpub{border-color:var(--unpub);color:var(--unpub)}
+.act.arch{border-color:var(--arch);color:var(--arch)}
+.act:disabled{opacity:.4;cursor:default}
+.actmsg{font-size:11px;color:var(--mut);min-height:14px}
+#more{display:block;margin:8px auto 60px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
+#empty{padding:60px;text-align:center;color:var(--mut)}
.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px}
.btn:hover{border-color:var(--acc)}
#selbar{position:fixed;left:0;right:0;bottom:0;z-index:20;background:#11131bF2;backdrop-filter:blur(10px);
- border-top:1px solid var(--acc);padding:11px 16px;display:none;align-items:center;gap:14px;flex-wrap:wrap}
+ border-top:1px solid var(--acc);padding:11px 16px;display:none;align-items:center;gap:12px;flex-wrap:wrap}
#selbar.on{display:flex}
#selbar .n{font-weight:700;color:var(--acc)}
-#launch{background:var(--acc);color:#04201a;font-weight:700;border:0;border-radius:8px;padding:8px 18px;cursor:pointer}
-#launch:disabled{opacity:.4;cursor:default}
-/* ── List view ── aligned compact rows via per-card CSS grid (display:contents
- hoists .b's children into the card grid so columns line up across rows). */
-.grid.list{grid-template-columns:1fr;gap:3px;padding:10px 16px}
-.grid.list .card{display:grid;grid-template-columns:26px 44px 150px 320px 110px 200px minmax(250px,1fr);
- align-items:center;gap:0 14px;padding:4px 14px;border-radius:8px;min-height:0}
-.grid.list .b{display:contents}
-.grid.list .pick{position:static;background:none;padding:0;justify-self:center}
-.grid.list .imgwrap{width:42px;height:42px;aspect-ratio:auto;border-radius:6px;margin:0}
-.grid.list .imgwrap.noimg::after{font-size:8px}
-.grid.list .vend{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.grid.list .ttl{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.grid.list .row{min-width:0;justify-content:flex-start;gap:8px}
-.grid.list .row .sub,.grid.list .row .sku{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.grid.list .when{margin:0;justify-content:flex-start;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px}
+#bulkgo{background:var(--acc);color:#04201a;font-weight:700;border:0;border-radius:8px;padding:8px 18px;cursor:pointer}
+#bulkgo:disabled{opacity:.4;cursor:default}
+/* ── List view ── */
+.grid.list{grid-template-columns:1fr;gap:4px;padding:12px 16px}
+.grid.list .card{flex-direction:row;align-items:center;border-radius:8px;flex-wrap:wrap}
+.grid.list .imgwrap{width:52px;min-width:52px;height:52px;aspect-ratio:auto;border-radius:6px;overflow:hidden;margin:6px 0 6px 6px}
+.grid.list .imgwrap.noimg::after{font-size:9px}
+.grid.list .pick{position:static;background:none;padding:0 6px 0 8px;flex:0 0 auto}
+.grid.list .b{flex:1;flex-direction:row;align-items:center;gap:14px;padding:6px 14px;flex-wrap:nowrap;overflow:hidden}
+.grid.list .vend{order:1;flex:0 0 150px;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .ttl{order:2;flex:1 1 200px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .chips{order:3;flex:0 0 auto}
+.grid.list .details{flex:1 1 100%;order:5}
</style></head>
<body>
<header>
- <h1>Products New to Import</h1>
+ <h1>Catalog · Shopify state</h1>
<span class="pill" id="total">…</span>
<span class="muted" id="meta"></span>
+ <div class="ctl"><label class="muted">Status</label>
+ <select id="status">
+ <option value="new">🟠 New to import</option>
+ <option value="onshopify">🔵 On Shopify (all)</option>
+ <option value="published">🟢 Published</option>
+ <option value="unpublished">⚪ Unpublished</option>
+ <option value="archived">🔴 Archived</option>
+ <option value="all">▦ All</option>
+ </select></div>
<div class="ctl"><label class="muted">Vendor</label>
<select id="vendor"><option value="">All vendors</option></select></div>
<div class="ctl"><input type="text" id="q" placeholder="search pattern / sku / color…"></div>
@@ -79,6 +109,7 @@ a.open:hover{background:var(--panel)}
<input type="range" id="density" min="3" max="10" step="1"></div>
<button class="btn" id="view">▤ List view</button>
<button class="btn" id="selall">☑ Select all shown</button>
+ <span class="warn" id="livebadge" style="display:none">LIVE WRITES ON</span>
</header>
<div class="grid" id="grid"></div>
<button id="more" style="display:none">Load more</button>
@@ -86,44 +117,75 @@ a.open:hover{background:var(--panel)}
<div id="selbar">
<span class="n" id="seln">0 selected</span>
<button class="btn" id="clearsel">clear</button>
- <span class="muted">launch as</span>
- <select id="mode"><option value="draft">DRAFT (review first)</option><option value="active">ACTIVE (live on create)</option></select>
- <button id="launch">🚀 Launch to Shopify ▸</button>
- <span class="muted" id="launchmsg"></span>
+ <span class="muted">bulk action</span>
+ <select id="bulkact">
+ <option value="launch:draft">🚀 Stage launch → DRAFT</option>
+ <option value="launch:active">🚀 Stage launch → ACTIVE</option>
+ <option value="publish:">🟢 Stage publish</option>
+ <option value="unpublish:">⚪ Stage unpublish</option>
+ <option value="archive:">🔴 Stage archive</option>
+ </select>
+ <button id="bulkgo">Apply ▸</button>
+ <span class="muted" id="bulkmsg"></span>
</div>
<script>
const $=s=>document.querySelector(s);
-const grid=$('#grid'), state={offset:0,limit:120,count:0,loading:false};
+const grid=$('#grid'), state={offset:0,limit:120,count:0,loading:false,live:false};
const sel=new Set();
-function syncSel(){const n=sel.size;$('#seln').textContent=n.toLocaleString()+' selected';$('#selbar').classList.toggle('on',n>0);$('#launch').disabled=!n;}
+const STATUS_LABEL={NEW:'NEW',PUBLISHED:'PUBLISHED',UNPUBLISHED:'UNPUBLISHED',ARCHIVED:'ARCHIVED',DELETED:'DELETED',ON_SHOPIFY:'ON SHOPIFY'};
+function syncSel(){const n=sel.size;$('#seln').textContent=n.toLocaleString()+' selected';$('#selbar').classList.toggle('on',n>0);$('#bulkgo').disabled=!n;}
function togglePick(id,cb){if(cb.checked)sel.add(id);else sel.delete(id);cb.closest('.card').classList.toggle('sel',cb.checked);syncSel();}
+function toggleCard(el){el.closest('.card').classList.toggle('open');}
// persisted prefs (CLAUDE.md: sort + density persist in localStorage)
+$('#status').value=localStorage.getItem('nip.status')||'new';
$('#sort').value=localStorage.getItem('nip.sort')||'newest';
const dens=localStorage.getItem('nip.density')||'6'; $('#density').value=dens;
document.documentElement.style.setProperty('--cols',dens);
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
+const SHOP='https://admin.shopify.com/store/designer-wallcoverings/products/';
+// Which staged actions make sense for a given state.
+function actionsFor(st){
+ if(st==='NEW') return [['launch:draft','Stage → Draft',''],['launch:active','Stage → Active','pub']];
+ if(st==='PUBLISHED') return [['unpublish:','Unpublish','unpub'],['archive:','Archive','arch']];
+ if(st==='UNPUBLISHED') return [['publish:','Publish','pub'],['archive:','Archive','arch']];
+ if(st==='ARCHIVED') return [['publish:','Publish (restore)','pub']];
+ if(st==='ON_SHOPIFY') return [['publish:','Publish','pub'],['unpublish:','Unpublish','unpub'],['archive:','Archive','arch']];
+ return [];
+}
function card(p){
const title=esc(p.pattern||p.sku||'(untitled)')+(p.color?(', '+esc(p.color)):'');
const img=p.image?`<div class="imgwrap"><img loading="lazy" src="${esc(p.image)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"></div>`
:`<div class="imgwrap noimg"></div>`;
- const crawled=p.crawled?`🕓 ${esc(p.crawled)}`:'🕓 (no crawl date)';
- const seen=p.firstSeen?` · first seen ${esc(p.firstSeen)}`:'';
const checked=sel.has(p.id)?'checked':'';
- return `<div class="card${checked?' sel':''}" data-id="${p.id}">
+ const st=p.shopStatus||'NEW';
+ const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
+ const shopLink=p.handle?`<a class="lnk" href="${SHOP}${esc(p.handle)}" target="_blank" rel="noopener noreferrer">Shopify admin ↗</a>`
+ :(p.shopifyId?`<a class="lnk" href="${SHOP}${esc(p.shopifyId)}" target="_blank" rel="noopener noreferrer">Shopify admin ↗</a>`:'');
+ const vendLink=p.url?`<a class="lnk" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">vendor ↗</a>`:'';
+ const acts=actionsFor(st).map(([a,label,cls])=>`<button class="act ${cls}" onclick="rowAction(${p.id},'${a}',this)">${label}</button>`).join('');
+ return `<div class="card${checked?' sel':''}" data-id="${p.id}" data-st="${st}">
<label class="pick"><input type="checkbox" ${checked} onchange="togglePick(${p.id},this)"></label>
${img}<div class="b">
- <span class="vend">${esc(p.vendor)}${p.collection?(' · '+esc(p.collection)):''}</span>
+ <span class="vend">${esc(p.vendor)}</span>
<div class="ttl">${title}</div>
- <div class="row"><span class="sub">${esc(p.type||'')}</span>
- ${p.url?`<a class="open" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">vendor ↗</a>`:''}</div>
- <div class="row"><span class="sku">${esc(p.sku||'')}${p.dwSku?(' · '+esc(p.dwSku)):''}</span></div>
- <div class="when" title="crawl date+time (when this run last saw it on the vendor site)">${crawled}${seen}</div>
+ <div class="chips">
+ <span class="chip st-${st}">${STATUS_LABEL[st]||st}</span>
+ <span class="chip toggle" onclick="toggleCard(this)">⋯ details & actions</span>
+ </div>
+ <div class="details">
+ ${kv('Vendor SKU',p.sku)}${kv('DW SKU',p.dwSku)}${kv('Collection',p.collection)}
+ ${kv('Type',p.type)}${kv('Sync status',p.syncStatus)}${kv('Shopify ID',p.shopifyId)}
+ ${kv('First seen',p.firstSeen)}${kv('Last crawl',p.crawled)}${kv('Shopify synced',p.shopSynced)}
+ <div class="chips">${vendLink}${shopLink}</div>
+ <div class="acts">${acts}</div>
+ <div class="actmsg" id="msg-${p.id}"></div>
+ </div>
</div></div>`;
}
async function load(reset){
if(state.loading) return; state.loading=true;
if(reset){ state.offset=0; grid.innerHTML=''; }
- const p=new URLSearchParams({vendor:$('#vendor').value,sort:$('#sort').value,q:$('#q').value,limit:state.limit,offset:state.offset});
+ const p=new URLSearchParams({status:$('#status').value,vendor:$('#vendor').value,sort:$('#sort').value,q:$('#q').value,limit:state.limit,offset:state.offset});
const d=await (await fetch('/api/new-products?'+p)).json();
state.count=d.count; state.offset+=d.items.length;
grid.insertAdjacentHTML('beforeend', d.items.map(card).join(''));
@@ -132,13 +194,26 @@ async function load(reset){
$('#more').style.display=state.offset<d.count?'block':'none';
state.loading=false;
}
+async function reloadVendors(){
+ const vs=await (await fetch('/api/vendors?status='+$('#status').value)).json();
+ $('#vendor').innerHTML='<option value="">All vendors</option>'+vs.map(v=>`<option value="${esc(v.vendor)}">${esc(v.vendor)} (${v.count.toLocaleString()})</option>`).join('');
+}
+function setHeaderCount(counts){
+ const cur=$('#status').value;
+ $('#total').textContent=((counts||{})[cur]!=null?counts[cur]:0).toLocaleString()+' '+cur;
+}
async function boot(){
const s=await (await fetch('/api/stats')).json();
- $('#total').textContent=s.total.toLocaleString()+' new';
- const vs=await (await fetch('/api/vendors')).json();
- $('#vendor').insertAdjacentHTML('beforeend', vs.map(v=>`<option value="${esc(v.vendor)}">${esc(v.vendor)} (${v.count.toLocaleString()})</option>`).join(''));
- load(true);
+ state.live=s.live; const c=s.counts||{};
+ setHeaderCount(c);
+ [['new','🟠 New to import'],['onshopify','🔵 On Shopify (all)'],['published','🟢 Published'],['unpublished','⚪ Unpublished'],['archived','🔴 Archived'],['all','▦ All']].forEach(([k,lbl])=>{
+ const o=$('#status').querySelector(`option[value="${k}"]`); if(o&&c[k]!=null) o.textContent=`${lbl} (${c[k].toLocaleString()})`;
+ });
+ if(s.live) $('#livebadge').style.display='inline';
+ window.__counts=c;
+ await reloadVendors(); load(true);
}
+$('#status').onchange=async()=>{localStorage.setItem('nip.status',$('#status').value);setHeaderCount(window.__counts);await reloadVendors();load(true);};
$('#vendor').onchange=()=>load(true);
$('#sort').onchange=()=>{localStorage.setItem('nip.sort',$('#sort').value);load(true);};
let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>load(true),300);};
@@ -149,18 +224,30 @@ $('#view').onclick=()=>{localStorage.setItem('nip.view',localStorage.getItem('ni
applyView();
$('#selall').onclick=()=>{grid.querySelectorAll('.card').forEach(c=>{const id=+c.dataset.id;if(!id)return;sel.add(id);c.classList.add('sel');const cb=c.querySelector('.pick input');if(cb)cb.checked=true;});syncSel();};
$('#clearsel').onclick=()=>{sel.clear();grid.querySelectorAll('.card.sel').forEach(c=>{c.classList.remove('sel');const cb=c.querySelector('.pick input');if(cb)cb.checked=false;});syncSel();};
-$('#launch').onclick=async()=>{
+async function rowAction(id,spec,btn){
+ const [action,mode]=spec.split(':');
+ if(!confirm(`${action.toUpperCase()} 1 product?\n\nThis STAGES the action to the launch queue. ${state.live?'Live writes ON but per-action push not yet wired — still stages only.':'Live Shopify writes are OFF — nothing reaches the store.'}`))return;
+ btn.disabled=true; const msg=$('#msg-'+id); if(msg)msg.textContent='staging…';
+ try{
+ const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[id],action,mode})});
+ const d=await r.json(); if(d.error)throw new Error(d.error);
+ if(msg)msg.textContent=`✅ staged ${action}${mode?(' ('+mode+')'):''} — batch ${d.batchId}`;
+ }catch(e){if(msg)msg.textContent='✗ '+e.message;}
+ btn.disabled=false;
+}
+window.rowAction=rowAction; window.togglePick=togglePick; window.toggleCard=toggleCard;
+$('#bulkgo').onclick=async()=>{
const ids=[...sel]; if(!ids.length)return;
- const mode=$('#mode').value;
- if(!confirm(`Launch ${ids.length} product(s) to Shopify as ${mode.toUpperCase()}?\n\nThis STAGES the batch into the launch queue. The live Shopify push is a separate gated step — nothing goes to the store yet.`))return;
- $('#launch').disabled=true; $('#launchmsg').textContent='staging…';
+ const [action,mode]=$('#bulkact').value.split(':');
+ if(!confirm(`${action.toUpperCase()} ${ids.length} product(s)${mode?(' as '+mode.toUpperCase()):''}?\n\nStages to the launch queue. ${state.live?'Live writes ON but per-action push not yet wired — stages only.':'Live Shopify writes are OFF — nothing reaches the store.'}`))return;
+ $('#bulkgo').disabled=true; $('#bulkmsg').textContent='staging…';
try{
- const r=await fetch('/api/launch',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids,mode})});
+ const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids,action,mode})});
const d=await r.json(); if(d.error)throw new Error(d.error);
const vlist=Object.entries(d.byVendor||{}).map(([v,n])=>`${v}:${n}`).join(', ');
- $('#launchmsg').textContent=`✅ staged ${d.staged} as ${d.mode} — ${vlist}`;
- }catch(e){$('#launchmsg').textContent='✗ '+e.message;}
- $('#launch').disabled=false;
+ $('#bulkmsg').textContent=`✅ staged ${d.staged} ${action} — ${vlist}`;
+ }catch(e){$('#bulkmsg').textContent='✗ '+e.message;}
+ $('#bulkgo').disabled=false;
};
addEventListener('scroll',()=>{if(innerHeight+scrollY>=document.body.offsetHeight-600)load(false);});
syncSel();
diff --git a/server.js b/server.js
index 9093463..07cd2b8 100644
--- a/server.js
+++ b/server.js
@@ -1,8 +1,9 @@
#!/usr/bin/env node
// new-import-viewer — the "Products New to Import" agent + web viewer.
-// Surfaces every vendor_catalog row the crawl flagged as NOT yet on Shopify
-// (sync_status='new' / on_shopify=false) so Steve can review what the latest
-// vendor crawl surfaced before a Phase-8 push. Read-only against dw_unified.
+// Surfaces vendor_catalog rows by Shopify state (NEW / PUBLISHED / UNPUBLISHED /
+// ARCHIVED) so Steve can review the catalog and stage actions before a Phase-8
+// push. Read-only against dw_unified EXCEPT the staged launch-queue. Live Shopify
+// writes are GATED behind SHOPIFY_LIVE_WRITES=1 (default off → stage-only).
// Zero npm deps — shells out to psql (same pattern as wallco-live-viewer).
const http = require('http');
const fs = require('fs');
@@ -13,36 +14,89 @@ const PORT = process.env.PORT || 9790;
const PSQL = process.env.PSQL || '/opt/homebrew/opt/postgresql@14/bin/psql';
const PGUSER = process.env.PGUSER || 'stevestudio2';
const PGDB = process.env.PGDATABASE || 'dw_unified';
+const LIVE = process.env.SHOPIFY_LIVE_WRITES === '1'; // gate: real store writes
const US = '\x1f'; // unit separator — safe field delim for text columns
const RS = '\x1e'; // record separator
// NEW-to-import predicate — the single source of truth for "new vs Shopify".
-const NEW_PRED = "(sync_status='new' OR ((on_shopify IS NOT TRUE) AND shopify_product_id IS NULL))";
+const NEW_PRED = "(vc.sync_status='new' OR ((vc.on_shopify IS NOT TRUE) AND vc.shopify_product_id IS NULL))";
+// Resolve one shopify_products row per vendor_catalog row. shopify_id is a
+// gid://shopify/Product/<n> string (UNIQUE index); vc.shopify_product_id is the
+// bare bigint. Construct the gid on the vc side so the unique index on
+// shopify_id does the lookup (don't wrap shopify_id in a function — that defeats
+// the index and seq-scans per row). NULL pid → NULL gid → no match, cheap.
+const SP_JOIN = `LEFT JOIN shopify_products sp
+ ON sp.shopify_id = 'gid://shopify/Product/' || vc.shopify_product_id::text`;
+// Derived, normalized Shopify state for a row. GROUND TRUTH wins: if a live
+// shopify_products row matches, show its real status even when the pipeline
+// still flags the row sync_status='new' (that mismatch is itself a useful
+// dedup signal — "flagged new but actually already live as X").
+const SHOP_STATUS = `CASE
+ WHEN upper(sp.status)='ACTIVE' THEN 'PUBLISHED'
+ WHEN upper(sp.status)='DRAFT' THEN 'UNPUBLISHED'
+ WHEN upper(sp.status)='ARCHIVED' THEN 'ARCHIVED'
+ WHEN upper(sp.status) LIKE 'DELETED%' THEN 'DELETED'
+ WHEN vc.shopify_product_id IS NOT NULL OR vc.on_shopify IS TRUE THEN 'ON_SHOPIFY'
+ ELSE 'NEW' END`;
+// Status filters the UI can request. Keys map to a WHERE fragment.
+const STATUS_FILTERS = {
+ new: NEW_PRED,
+ all: 'TRUE',
+ onshopify: '(vc.shopify_product_id IS NOT NULL OR vc.on_shopify IS TRUE)',
+ published: "upper(sp.status)='ACTIVE'",
+ unpublished: "upper(sp.status)='DRAFT'",
+ archived: "upper(sp.status)='ARCHIVED'",
+};
function q(sql) {
const r = spawnSync(PSQL, ['-U', PGUSER, '-d', PGDB, '-tA', '-F', US, '-R', RS, '-c', sql],
- { encoding: 'utf8', timeout: 30000, maxBuffer: 1 << 28 });
+ { encoding: 'utf8', timeout: 60000, maxBuffer: 1 << 28 });
if (r.status !== 0) throw new Error((r.stderr || 'psql failed').slice(0, 400));
- // psql appends a trailing newline to the whole output — strip it before splitting
- // so the final field of the final row doesn't carry a stray \n.
return (r.stdout || '').replace(/\n+$/, '').split(RS).filter(l => l.length).map(l => l.split(US));
}
const esc = (s) => String(s == null ? '' : s).replace(/'/g, "''");
const SORTS = {
- newest: 'last_scraped_at DESC NULLS LAST, id DESC',
- oldest: 'last_scraped_at ASC NULLS LAST, id ASC',
- firstseen: 'first_seen_at DESC NULLS LAST, id DESC',
- vendor: "vendor_code ASC, pattern_name ASC",
- sku: "mfr_sku ASC",
- pattern: "pattern_name ASC, color_name ASC",
- color: "color_name ASC, pattern_name ASC",
+ newest: 'vc.last_scraped_at DESC NULLS LAST, vc.id DESC',
+ oldest: 'vc.last_scraped_at ASC NULLS LAST, vc.id ASC',
+ firstseen: 'vc.first_seen_at DESC NULLS LAST, vc.id DESC',
+ vendor: "vc.vendor_code ASC, vc.pattern_name ASC",
+ sku: "vc.mfr_sku ASC",
+ pattern: "vc.pattern_name ASC, vc.color_name ASC",
+ color: "vc.color_name ASC, vc.pattern_name ASC",
};
+// Build the WHERE clause shared by listing + counts.
+function buildWhere({ status, vendor, term }) {
+ const parts = [STATUS_FILTERS[status] || NEW_PRED];
+ if (vendor) parts.push(`vc.vendor_code='${esc(vendor)}'`);
+ if (term) {
+ const t = `%${esc(term)}%`;
+ parts.push(`(vc.pattern_name ILIKE '${t}' OR vc.mfr_sku ILIKE '${t}' OR vc.color_name ILIKE '${t}' OR vc.dw_sku ILIKE '${t}' OR vc.collection ILIKE '${t}')`);
+ }
+ return 'WHERE ' + parts.join(' AND ');
+}
+
function send(res, code, body, type) {
res.writeHead(code, { 'content-type': type || 'application/json', 'cache-control': 'no-store' });
res.end(body);
}
+// Stage an action to the append-only launch queue. NEVER writes to the store.
+function stageAction({ ids, action, mode }) {
+ const idList = ids.join(',');
+ const rows = q(`SELECT vc.id, vc.vendor_code, vc.mfr_sku, vc.dw_sku, vc.pattern_name, ${SHOP_STATUS} AS st
+ FROM vendor_catalog vc ${SP_JOIN}
+ WHERE vc.id IN (${idList})`);
+ const batchId = action + '-' + rows.length + '-' + (ids[0] || 0) + '-' + Date.now().toString(36);
+ const skus = rows.map(r => ({ id: +r[0], vendor: r[1], sku: r[2], dwSku: r[3], pattern: r[4], from: r[5] }));
+ const byVendor = {};
+ rows.forEach(r => (byVendor[r[1]] = (byVendor[r[1]] || 0) + 1));
+ const qf = path.join(__dirname, 'data', 'launch-queue.jsonl');
+ fs.mkdirSync(path.dirname(qf), { recursive: true });
+ fs.appendFileSync(qf, JSON.stringify({ batchId, action, mode, count: rows.length, skus: rows.length, at: new Date().toISOString() }) + '\n');
+ return { ok: true, staged: rows.length, action, mode, batchId, byVendor, live: false };
+}
+
const server = http.createServer((req, res) => {
try {
const u = new URL(req.url, 'http://x');
@@ -50,68 +104,81 @@ const server = http.createServer((req, res) => {
return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html')), 'text/html; charset=utf-8');
}
if (u.pathname === '/api/stats') {
- const r = q(`SELECT count(*), count(*) FILTER (WHERE image_url<>''), count(DISTINCT vendor_code),
- to_char(max(last_scraped_at),'YYYY-MM-DD HH24:MI')
- FROM vendor_catalog WHERE ${NEW_PRED}`)[0] || [];
- return send(res, 200, JSON.stringify({ total: +r[0] || 0, withImage: +r[1] || 0, vendors: +r[2] || 0, newestCrawl: r[3] || null }));
+ // Whole-catalog status breakdown for the header pills (vendor/term ignored here).
+ const r = q(`SELECT
+ count(*) FILTER (WHERE ${NEW_PRED}),
+ count(*) FILTER (WHERE vc.shopify_product_id IS NOT NULL OR vc.on_shopify IS TRUE),
+ count(*) FILTER (WHERE upper(sp.status)='ACTIVE'),
+ count(*) FILTER (WHERE upper(sp.status)='DRAFT'),
+ count(*) FILTER (WHERE upper(sp.status)='ARCHIVED'),
+ count(*),
+ to_char(max(vc.last_scraped_at),'YYYY-MM-DD HH24:MI')
+ FROM vendor_catalog vc ${SP_JOIN}`)[0] || [];
+ return send(res, 200, JSON.stringify({
+ live: LIVE,
+ counts: { new: +r[0] || 0, onshopify: +r[1] || 0, published: +r[2] || 0,
+ unpublished: +r[3] || 0, archived: +r[4] || 0, all: +r[5] || 0 },
+ newestCrawl: r[6] || null,
+ }));
}
if (u.pathname === '/api/vendors') {
- const rows = q(`SELECT vendor_code, count(*),
- to_char(max(last_scraped_at),'YYYY-MM-DD HH24:MI')
- FROM vendor_catalog WHERE ${NEW_PRED}
- GROUP BY vendor_code ORDER BY count(*) DESC`);
+ const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
+ const where = buildWhere({ status, vendor: '', term: '' });
+ const rows = q(`SELECT vc.vendor_code, count(*),
+ to_char(max(vc.last_scraped_at),'YYYY-MM-DD HH24:MI')
+ FROM vendor_catalog vc ${SP_JOIN} ${where}
+ GROUP BY vc.vendor_code ORDER BY count(*) DESC`);
return send(res, 200, JSON.stringify(rows.map(r => ({ vendor: r[0], count: +r[1], crawled: r[2] }))));
}
if (u.pathname === '/api/new-products') {
+ const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
const vendor = (u.searchParams.get('vendor') || '').replace(/[^a-z0-9_]/gi, '').slice(0, 64);
const sort = SORTS[u.searchParams.get('sort')] ? u.searchParams.get('sort') : 'newest';
const term = (u.searchParams.get('q') || '').slice(0, 80);
const limit = Math.min(parseInt(u.searchParams.get('limit'), 10) || 120, 500);
const offset = Math.max(parseInt(u.searchParams.get('offset'), 10) || 0, 0);
- let where = `WHERE ${NEW_PRED}`;
- if (vendor) where += ` AND vendor_code='${esc(vendor)}'`;
- if (term) {
- const t = `%${esc(term)}%`;
- where += ` AND (pattern_name ILIKE '${t}' OR mfr_sku ILIKE '${t}' OR color_name ILIKE '${t}' OR dw_sku ILIKE '${t}' OR collection ILIKE '${t}')`;
- }
- const rows = q(`SELECT id, vendor_code, mfr_sku, dw_sku, pattern_name, color_name, collection,
- product_type, image_url, product_url,
- to_char(first_seen_at,'YYYY-MM-DD HH24:MI') AS first_seen,
- to_char(last_scraped_at,'YYYY-MM-DD HH24:MI') AS crawled,
- sync_status
- FROM vendor_catalog ${where}
+ const where = buildWhere({ status, vendor, term });
+ const rows = q(`SELECT vc.id, vc.vendor_code, vc.mfr_sku, vc.dw_sku, vc.pattern_name, vc.color_name, vc.collection,
+ vc.product_type, vc.image_url, vc.product_url,
+ to_char(vc.first_seen_at,'YYYY-MM-DD HH24:MI') AS first_seen,
+ to_char(vc.last_scraped_at,'YYYY-MM-DD HH24:MI') AS crawled,
+ vc.sync_status, ${SHOP_STATUS} AS shop_status,
+ vc.shopify_product_id, sp.handle,
+ to_char(vc.shopify_synced_at,'YYYY-MM-DD HH24:MI') AS shop_synced
+ FROM vendor_catalog vc ${SP_JOIN} ${where}
ORDER BY ${SORTS[sort]} LIMIT ${limit} OFFSET ${offset}`);
- const cnt = +(q(`SELECT count(*) FROM vendor_catalog ${where}`)[0] || [0])[0];
+ const cnt = +(q(`SELECT count(*) FROM vendor_catalog vc ${SP_JOIN} ${where}`)[0] || [0])[0];
const items = rows.map(r => ({
id: +r[0], vendor: r[1], sku: r[2], dwSku: r[3], pattern: r[4], color: r[5],
collection: r[6], type: r[7], image: r[8], url: r[9],
- firstSeen: r[10], crawled: r[11], status: r[12],
+ firstSeen: r[10], crawled: r[11], syncStatus: r[12], shopStatus: r[13],
+ shopifyId: r[14] || null, handle: r[15] || null, shopSynced: r[16] || null,
}));
return send(res, 200, JSON.stringify({ count: cnt, offset, limit, items }));
}
- if (u.pathname === '/api/launch' && req.method === 'POST') {
+ // Generalized action endpoint — stages launch / publish / unpublish / archive.
+ // /api/launch kept as a back-compat alias (action='launch').
+ if ((u.pathname === '/api/action' || u.pathname === '/api/launch') && req.method === 'POST') {
let body = '';
req.on('data', c => (body += c));
req.on('end', () => {
try {
const d = JSON.parse(body || '{}');
const ids = Array.isArray(d.ids) ? d.ids.filter(n => Number.isInteger(n)).slice(0, 5000) : [];
- const mode = d.mode === 'active' ? 'active' : 'draft';
+ const ACTIONS = ['launch', 'publish', 'unpublish', 'archive'];
+ const action = u.pathname === '/api/launch' ? 'launch'
+ : (ACTIONS.includes(d.action) ? d.action : null);
+ if (!action) return send(res, 400, JSON.stringify({ error: 'unknown action' }));
+ const mode = d.mode === 'active' ? 'active' : 'draft'; // only meaningful for launch
if (!ids.length) return send(res, 400, JSON.stringify({ error: 'no products selected' }));
- // Resolve the selected rows so the manifest carries vendor + sku, not bare ids.
- const rows = q(`SELECT id, vendor_code, mfr_sku, dw_sku, pattern_name
- FROM vendor_catalog WHERE id IN (${ids.join(',')}) AND ${NEW_PRED}`);
- const batchId = 'launch-' + rows.length + '-' + ids[0];
- const entry = { batchId, mode, count: rows.length,
- skus: rows.map(r => ({ id: +r[0], vendor: r[1], sku: r[2], dwSku: r[3], pattern: r[4] })) };
- // STAGE only — append to a launch queue. The live Shopify push (Phase 8)
- // is a separate gated step; this never writes to the store.
- const qf = path.join(__dirname, 'data', 'launch-queue.jsonl');
- fs.mkdirSync(path.dirname(qf), { recursive: true });
- fs.appendFileSync(qf, JSON.stringify({ ...entry, skus: entry.skus.length }) + '\n');
- const byVendor = {};
- rows.forEach(r => (byVendor[r[1]] = (byVendor[r[1]] || 0) + 1));
- return send(res, 200, JSON.stringify({ ok: true, staged: rows.length, mode, batchId, byVendor }));
+ const out = stageAction({ ids, action, mode });
+ // LIVE writes are scaffolded but gated. When SHOPIFY_LIVE_WRITES=1 this is
+ // where the Shopify Admin API call would fire per action; left disabled
+ // until Steve OKs each action type.
+ out.note = LIVE
+ ? 'staged — live writes ENABLED but per-action push not yet wired (awaiting sign-off)'
+ : 'staged only — live Shopify writes are OFF (SHOPIFY_LIVE_WRITES!=1)';
+ return send(res, 200, JSON.stringify(out));
} catch (e) { return send(res, 500, JSON.stringify({ error: e.message })); }
});
return;
@@ -121,4 +188,4 @@ const server = http.createServer((req, res) => {
return send(res, 500, JSON.stringify({ error: e.message }));
}
});
-server.listen(PORT, () => console.log(`[new-import-viewer] http://127.0.0.1:${PORT} (NEW-to-import from dw_unified.vendor_catalog)`));
+server.listen(PORT, () => console.log(`[new-import-viewer] http://127.0.0.1:${PORT} (Shopify-state catalog · live-writes=${LIVE ? 'ON' : 'OFF'})`));
← af2b077 snapshot before shopify-status + collapsed-chips rebuild
·
back to New Import Viewer
·
Wire live Shopify unpublish behind double gate (master switc b2cb29b →