[object Object]

← back to New Import Viewer

HARD GATE: all products must have images (Steve 2026-06-10) — launch staging filters no-image rows server-side (single, bulk, raw API); launch buttons disabled on no-image cards with reason; bulk UI reports blocked count; non-launch actions unaffected

4fb4e880508d556c4c627cf7607bb881751c11f2 · 2026-06-10 07:16:21 -0700 · SteveStudio2

Files touched

Diff

commit 4fb4e880508d556c4c627cf7607bb881751c11f2
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed Jun 10 07:16:21 2026 -0700

    HARD GATE: all products must have images (Steve 2026-06-10) — launch staging filters no-image rows server-side (single, bulk, raw API); launch buttons disabled on no-image cards with reason; bulk UI reports blocked count; non-launch actions unaffected
---
 public/index.html | 30 ++++++++++++++++++------------
 server.js         | 19 ++++++++++++++++++-
 2 files changed, 36 insertions(+), 13 deletions(-)

diff --git a/public/index.html b/public/index.html
index c54ead0..f1abadf 100644
--- a/public/index.html
+++ b/public/index.html
@@ -235,8 +235,8 @@ function renderArm(){
 // "nina_campbell" → "Nina Campbell" (de-shout raw vendor codes)
 function fmtVendor(v){return String(v||'').replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase());}
 // Build the action-button row; live-armed actions get the danger treatment.
-function buildActs(id,st){
-  const defs=actionsFor(st);
+function buildActs(id,st,hasImg){
+  const defs=actionsFor(st,hasImg);
   const btns=defs.map(([a,label,cls,dis,why])=>{
     const live=!dis&&isLive(a.split(':')[0]);
     return `<button class="act ${cls}${live?' live':''}"${dis?` disabled title="${esc(why||'')}"`:''} onclick="rowAction(${id},'${a}',this)">${live?'⚡ ':''}${label}${live?' · LIVE':''}</button>`;
@@ -260,7 +260,7 @@ function updateCardStatus(id,st){
   const chip=c.querySelector('.b>.chips>.chip:not(.toggle)');
   if(chip){chip.className='chip st-'+st;chip.textContent=STATUS_LABEL[st]||st;}
   const actsEl=c.querySelector('.acts');
-  if(actsEl)actsEl.outerHTML=buildActs(id,st);
+  if(actsEl)actsEl.outerHTML=buildActs(id,st,c.dataset.img==='1');
 }
 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();}
@@ -283,11 +283,16 @@ const SHOP='https://admin.shopify.com/store/designer-laboratory-sandbox/products
 // with the reason in the tooltip instead of hidden. [action, label, class,
 // disabled, why]. Launch is hard-disabled on already-linked rows (staging a
 // launch there would create a DUPLICATE product — the one unforgivable sin).
-function actionsFor(st){
+function actionsFor(st,hasImg){
   const linked=st!=='NEW';
+  // HARD GATE (Steve 2026-06-10): all products must have images — a no-image
+  // row can never be launched (server blocks too; this is the honest UI).
+  const launchDis=linked||!hasImg;
+  const launchWhy=linked?'already linked to Shopify — launching again would create a duplicate'
+                        :'no image — ALL PRODUCTS MUST HAVE IMAGES (fix the scrape first)';
   return [
-    ['launch:draft','🚀 Stage → Draft','',linked,'already linked to Shopify — launching again would create a duplicate'],
-    ['launch:active','🚀 Stage → Active','pub',linked,'already linked to Shopify — launching again would create a duplicate'],
+    ['launch:draft','🚀 Stage → Draft','',launchDis,launchWhy],
+    ['launch:active','🚀 Stage → Active','pub',launchDis,launchWhy],
     ['publish:','🟢 Publish','pub',!linked||st==='PUBLISHED',!linked?'not on Shopify yet — stage a launch first':'already published'],
     ['unpublish:','⚪ Unpublish','unpub',!linked||st==='UNPUBLISHED',!linked?'not on Shopify yet — stage a launch first':'already unpublished'],
     ['archive:','🔴 Archive','arch',!linked||st==='ARCHIVED',!linked?'not on Shopify yet — stage a launch first':'already archived'],
@@ -304,14 +309,14 @@ function card(p){
                 :(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 storeLink=p.handle?`<a class="lnk" href="https://www.designerwallcoverings.com/products/${esc(p.handle)}" target="_blank" rel="noopener noreferrer">storefront ↗</a>`:'';
-  const acts=buildActs(p.id,st);
+  const acts=buildActs(p.id,st,!!p.image);
   // utility row — copy SKUs + every outbound link, always present in ⚙ actions
   const utils=`<div class="acts">
     ${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy vendor SKU">📋 SKU</button>`:''}
     ${p.dwSku?`<button class="act" data-copy="${esc(p.dwSku)}" onclick="copyTxt(this)" title="copy DW SKU">📋 DW</button>`:''}
     ${vendLink}${shopLink}${storeLink}
   </div>`;
-  return `<div class="card${checked?' sel':''}" data-id="${p.id}" data-st="${st}">
+  return `<div class="card${checked?' sel':''}" data-id="${p.id}" data-st="${st}" data-img="${p.image?1:0}">
     <label class="pick"><input type="checkbox" ${checked} onchange="togglePick(${p.id},this)"></label>
     ${img}<div class="b">
     <span class="vend">${esc(fmtVendor(p.vendor))}</span>
@@ -478,18 +483,19 @@ $('#bulkgo').onclick=async()=>{
   // server caps 5,000 ids per request — chunk so "select ALL matching" (50k+)
   // stages completely instead of silently truncating at the first 5k.
   try{
-    let staged=0; const byVendor={};
+    let staged=0,blockedNoImg=0; const byVendor={};
     for(let i=0;i<ids.length;i+=5000){
       const ch=ids.slice(i,i+5000);
       $('#bulkmsg').textContent=`staging… ${Math.min(i+ch.length,ids.length).toLocaleString()} / ${ids.length.toLocaleString()}`;
       const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:ch,action,mode,stageOnly:true,arm:false})});
-      const d=await r.json(); if(d.error)throw new Error(d.error);
-      staged+=d.staged||0;
+      const d=await r.json();
+      if(d.error){ if(d.noImageBlocked){blockedNoImg+=d.noImageBlocked;continue;} throw new Error(d.error); }
+      staged+=d.staged||0; blockedNoImg+=d.noImageBlocked||0;
       Object.entries(d.byVendor||{}).forEach(([v,n])=>byVendor[v]=(byVendor[v]||0)+n);
     }
     const vents=Object.entries(byVendor);
     const vlist=vents.slice(0,8).map(([v,n])=>`${v}:${n}`).join(', ')+(vents.length>8?` … +${vents.length-8} vendors`:'');
-    $('#bulkmsg').textContent=`✅ staged ${staged.toLocaleString()} ${action} — ${vlist}`;
+    $('#bulkmsg').textContent=`✅ staged ${staged.toLocaleString()} ${action} — ${vlist}`+(blockedNoImg?` · 🚫 ${blockedNoImg.toLocaleString()} blocked (no image)`:'');
   }catch(e){$('#bulkmsg').textContent='✗ '+e.message;}
   $('#bulkgo').disabled=false;
 };
diff --git a/server.js b/server.js
index 030bbeb..5f17c28 100644
--- a/server.js
+++ b/server.js
@@ -301,14 +301,31 @@ const server = http.createServer((req, res) => {
       req.on('end', async () => {
         try {
           const d = JSON.parse(body || '{}');
-          const ids = Array.isArray(d.ids) ? d.ids.filter(n => Number.isInteger(n)).slice(0, 5000) : [];
+          let ids = Array.isArray(d.ids) ? d.ids.filter(n => Number.isInteger(n)).slice(0, 5000) : [];
           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' }));
+          // HARD GATE (Steve 2026-06-10): ALL PRODUCTS MUST HAVE IMAGES.
+          // A launch may never stage a row without an image — filter server-side
+          // so neither single-card nor bulk (nor a raw curl) can sneak one in.
+          let noImageBlocked = 0;
+          if (action === 'launch') {
+            const withImg = new Set(q(`SELECT vc.id FROM vendor_catalog vc
+              WHERE vc.id IN (${ids.join(',')})
+                AND vc.image_url IS NOT NULL AND vc.image_url <> ''`).map(r => +r[0]));
+            noImageBlocked = ids.length - withImg.size;
+            ids = ids.filter(id => withImg.has(id));
+            if (!ids.length) return send(res, 400, JSON.stringify({
+              error: `all ${noImageBlocked} selected row(s) lack images — ALL PRODUCTS MUST HAVE IMAGES; fix the scrape, don't import around it`, noImageBlocked }));
+          }
           const out = stageAction({ ids, action, mode });
+          if (noImageBlocked) {
+            out.noImageBlocked = noImageBlocked;
+            out.note = `${noImageBlocked} no-image row(s) BLOCKED from launch (all products must have images)`;
+          }
 
           // SAFETY: bulk requests (stageOnly) NEVER fire live writes — live store
           // changes only ever happen one card at a time. The fat-finger blast

← 01963b9 harden: noimage boot-trap defused (persisted diagnostic filt  ·  back to New Import Viewer  ·  staging manifests: every batch persists full id/SKU manifest 863ce65 →