← 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
M public/index.htmlM server.js
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 →