[object Object]

← back to Wallco Ai

fix(cactus-curator): batch bulk-delete + drop removed SKUs from live index

3778fdeb8ae0ce1acbf83bac5db0078be4a2eb64 · 2026-05-29 09:27:08 -0700 · Steve Abrams

Two bugs in the /admin/cactus-curator bulk action bar:

1. BULK DELETE FORK STORM. The frontend bulk() fired an un-awaited decide()
   per selected id — N concurrent fetches, each hitting applyCactusDecision
   which spawns a psql process (psqlExecLocal=execSync). A few-hundred-card
   'Bad — remove all' became a few-hundred-way psql fork storm that timed out
   and left the delete half-applied. Now bulk() makes ONE batched request to
   /api/cactus-decision/bulk, which collapses bad/digital/fix to a single
   'WHERE id IN (...)' UPDATE (one psql spawn). 'live' stays per-id (each
   design must clear its own publish gate). Cards are removed from the grid on
   success; failures roll back the optimistic state.

2. REMOVED SKUs LINGERED ON THE LIVE INDEX. applyCactusDecision('bad'/'digital')
   only wrote PG — it never touched the in-memory DESIGNS array, so a removed
   design (and its SKU) stayed on the public /designs grid + /api/designs +
   by-color until the next refresh_designs_snapshot.py + reload-designs. New
   dropFromIndex(ids) helper splices removed designs out of the live catalog in
   one backward pass + bumps the by-color cache version, so SKUs leave every
   public surface immediately. Wired into both the single applyCactusDecision
   (bad/digital) and the batched bulk endpoint.

Tested on Mac2: bulk 'digital' on a published id returned dropped_from_index:1,
live index 2754->2753, design no longer served by /api/designs; restore put it
back to 2754. Input guards (empty ids, bad action) return 400. Frontend inline
JS syntax-checked.

Files touched

Diff

commit 3778fdeb8ae0ce1acbf83bac5db0078be4a2eb64
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 29 09:27:08 2026 -0700

    fix(cactus-curator): batch bulk-delete + drop removed SKUs from live index
    
    Two bugs in the /admin/cactus-curator bulk action bar:
    
    1. BULK DELETE FORK STORM. The frontend bulk() fired an un-awaited decide()
       per selected id — N concurrent fetches, each hitting applyCactusDecision
       which spawns a psql process (psqlExecLocal=execSync). A few-hundred-card
       'Bad — remove all' became a few-hundred-way psql fork storm that timed out
       and left the delete half-applied. Now bulk() makes ONE batched request to
       /api/cactus-decision/bulk, which collapses bad/digital/fix to a single
       'WHERE id IN (...)' UPDATE (one psql spawn). 'live' stays per-id (each
       design must clear its own publish gate). Cards are removed from the grid on
       success; failures roll back the optimistic state.
    
    2. REMOVED SKUs LINGERED ON THE LIVE INDEX. applyCactusDecision('bad'/'digital')
       only wrote PG — it never touched the in-memory DESIGNS array, so a removed
       design (and its SKU) stayed on the public /designs grid + /api/designs +
       by-color until the next refresh_designs_snapshot.py + reload-designs. New
       dropFromIndex(ids) helper splices removed designs out of the live catalog in
       one backward pass + bumps the by-color cache version, so SKUs leave every
       public surface immediately. Wired into both the single applyCactusDecision
       (bad/digital) and the batched bulk endpoint.
    
    Tested on Mac2: bulk 'digital' on a published id returned dropped_from_index:1,
    live index 2754->2753, design no longer served by /api/designs; restore put it
    back to 2754. Input guards (empty ids, bad action) return 400. Frontend inline
    JS syntax-checked.
---
 public/admin/cactus-curator.html |  53 ++++++++++++++++-
 server.js                        | 125 ++++++++++++++++++++++++++++++++++-----
 2 files changed, 162 insertions(+), 16 deletions(-)

diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 8cac933..36cf594 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -642,8 +642,57 @@ async function decide(id, action, el){
 async function bulk(action){
   const ids = [...selected];
   if(!ids.length) return;
-  if(action==='bad' && !confirm(`Remove ${ids.length} designs from all surfaces? (quarantines the PNGs)`)) return;
-  ids.forEach(id => { const el = grid.querySelector(`.card[data-id="${id}"]`); decide(id, action, el); });
+  if(action==='bad' && !confirm(`Remove ${ids.length} designs from all surfaces? (quarantines the PNGs + drops them from the live site)`)) return;
+
+  // ONE batched request — not N un-awaited decide() calls. The old loop fired a
+  // fetch per id; each spawned a server-side psql process, so a few-hundred-card
+  // bulk-remove became a psql fork storm that timed out and half-applied. The
+  // /api/cactus-decision/bulk endpoint now collapses bad/digital/fix to a single
+  // SQL UPDATE and drops the removed designs from the live index server-side.
+  const labels = {bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET'};
+  ids.forEach(id=>{ const el=grid.querySelector(`.card[data-id="${id}"]`); el?.classList.add('decided');
+    const st=el?.querySelector('.state'); if(st){ st.textContent=labels[action]; st.style.display='block'; } });
+
+  try{
+    let j;
+    if(action==='etsy'){
+      const r=await fetch(q('/api/etsy-bucket/add'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});
+      j=await r.json().catch(()=>({})); if(!r.ok||!j.ok) throw new Error(j.error||('HTTP '+r.status));
+    } else {
+      const r=await fetch(q('/api/cactus-decision/bulk'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,action})});
+      j=await r.json().catch(()=>({})); if(!r.ok||j.ok===false) throw new Error(j.error||('HTTP '+r.status));
+    }
+
+    ids.forEach(id=>{
+      const el=grid.querySelector(`.card[data-id="${id}"]`);
+      const d=ALL.find(x=>x.id===id);
+      decided.set(id, action);
+      if(action==='bad'){      if(d){d.is_published=false;d.user_removed=true;d.web_viewer=false;}  el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
+      else if(action==='digital'){ if(d){d.is_published=false;d.web_viewer=false;d.digital_file_at=today();} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
+      else if(action==='etsy'){    if(d){d.in_etsy_bucket=true;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
+      else if(action==='fix'){     if(d){d.needs_fixing_at=today();} }
+      else if(action==='live'){    if(d){d.is_published=true;d.web_viewer=true;d.user_removed=false;d.needs_fixing_at=null;d.digital_file_at=null;} }
+    });
+
+    // 'live' gate-held items come back parked as needs-fixing — reflect that.
+    if(action==='live' && Array.isArray(j.results)){
+      j.results.filter(rr=>rr&&rr.gate_held).forEach(rr=>{ decided.set(rr.id,'fix');
+        const st=grid.querySelector(`.card[data-id="${rr.id}"] .state`); if(st){ st.textContent='⚠ NEEDS FIXING'; } });
+    }
+
+    let msg = action==='live' ? `${j.applied||0} published${j.gate_held?`, ${j.gate_held} parked (gate)`:''}`
+            : action==='etsy' ? `${(j.results||[]).filter(x=>x&&x.ok).length||ids.length} → Etsy bucket`
+            : `${j.applied??ids.length} ${({bad:'removed',digital:'→ digital file',fix:'flagged needs-fixing'})[action]}`;
+    if(j.dropped_from_index!=null) msg += ` · ${j.dropped_from_index} dropped from live index`;
+    if(j.errored) msg += ` · ${j.errored} errored`;
+    flash(msg);
+  }catch(e){
+    ids.forEach(id=>{ const el=grid.querySelector(`.card[data-id="${id}"]`); el?.classList.remove('decided');
+      const st=el?.querySelector('.state'); if(st) st.style.display='none'; decided.delete(id); });
+    flash(`Bulk ${action} failed: ${e.message}`);
+  }
+  updateStat();
+  selected.forEach(id=>grid.querySelector(`.card[data-id="${id}"]`)?.classList.remove('sel'));
   selected.clear(); $('bulkn').textContent=0; $('bulk').classList.remove('show');
 }
 $('bulk').querySelectorAll('button[data-a]').forEach(b => b.addEventListener('click', ()=>bulk(b.dataset.a)));
diff --git a/server.js b/server.js
index 0626f9e..873a7ca 100644
--- a/server.js
+++ b/server.js
@@ -1942,6 +1942,24 @@ app.get('/api/admin/cactus/:id', (req, res, next) => {
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// Drop a set of design ids from the live in-memory catalog so their SKUs leave
+// every public surface (the /designs grid, /api/designs, search, by-color)
+// IMMEDIATELY — no refresh_designs_snapshot.py + reload-designs round-trip.
+// One backward pass so splices don't skip neighbours; O(n) total instead of
+// N×findIndex. Bumps the by-color cache version like /admin/reload-designs does
+// so stale palette/ΔE results can't outlive the removed designs. Returns the
+// count actually spliced out. Used by curator bulk-remove + applyCactusDecision.
+function dropFromIndex(ids) {
+  const set = new Set((Array.isArray(ids) ? ids : [ids]).map(n => parseInt(n, 10)).filter(Number.isFinite));
+  if (!set.size) return 0;
+  let n = 0;
+  for (let i = DESIGNS.length - 1; i >= 0; i--) {
+    if (set.has(DESIGNS[i].id)) { DESIGNS.splice(i, 1); n++; }
+  }
+  if (n) { _byColorCacheVer++; _byColorCache.clear(); }
+  return n;
+}
+
 // Apply one cactus verdict. body { action: 'bad'|'digital'|'fix'|'live' }.
 function applyCactusDecision(id, action, opts = {}) {
   const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, local_path FROM all_designs WHERE id=${id}) t;`);
@@ -1958,12 +1976,14 @@ function applyCactusDecision(id, action, opts = {}) {
         if (!fs.existsSync(dest)) fs.renameSync(row.local_path, dest);
       } catch (e) { console.warn('[cactus bad] quarantine move failed:', e.message); }
     }
+    dropFromIndex([id]);   // SKU leaves the live public index immediately
   } else if (action === 'digital') {
     // 2) Unpublish, sell as digital file — stamp the date field.
     psqlExecLocal(`UPDATE all_designs
       SET is_published=FALSE, web_viewer=FALSE, digital_file_at=now(),
           tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'digital-file') || ARRAY['digital-file']::text[]
       WHERE id=${id};`);
+    dropFromIndex([id]);   // unpublished → drop from the live public index too
   } else if (action === 'fix') {
     // 3) Keep but needs fixing — flag + date, leave publish state as-is.
     psqlExecLocal(`UPDATE all_designs
@@ -2025,22 +2045,78 @@ app.post('/api/cactus-decision/:id', express.json({ limit: '2kb' }), (req, res,
 });
 
 // Bulk variant — one HTTP call for a multi-select action bar.
-app.post('/api/cactus-decision/bulk', express.json({ limit: '64kb' }), (req, res) => {
+//
+// The old version looped applyCactusDecision() per id. Each of those spawns a
+// psql process (psqlExecLocal = execSync), and the curator fired the whole
+// selection as N concurrent un-awaited fetches — so a bulk-remove of a few
+// hundred designs became a few-hundred-way psql fork storm that timed out and
+// left the delete half-applied. THE bulk-delete bug.
+//
+// Now: bad/digital/fix collapse to ONE batched UPDATE (one psql spawn) over the
+// whole id set, PNGs quarantine in a plain fs loop, and the removed designs are
+// spliced out of the live in-memory index in one pass (dropFromIndex) so their
+// SKUs leave the public catalog immediately. 'live' keeps the per-id path —
+// each design must clear its own publish gate, so it can't be one statement.
+app.post('/api/cactus-decision/bulk', express.json({ limit: '256kb' }), (req, res) => {
   if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
   const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(n => parseInt(n, 10)).filter(Number.isFinite) : [];
   const action = String(req.body?.action || '').toLowerCase();
   if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
   if (!['bad', 'digital', 'fix', 'live'].includes(action)) return res.status(400).json({ error: 'bad action' });
-  let ok = 0, err = 0, held = 0; const results = [];
-  for (const id of ids) {
-    try {
-      const r = applyCactusDecision(id, action);
-      if (r && r.gate_held) { results.push({ id, ok: true, gate_held: true, reasons: r.reasons }); held++; }
-      else                  { results.push({ id, ok: true }); ok++; }
+
+  // 'live' — per-id, because each design runs its own publish gate.
+  if (action === 'live') {
+    let ok = 0, err = 0, held = 0; const results = [];
+    for (const id of ids) {
+      try {
+        const r = applyCactusDecision(id, action);
+        if (r && r.gate_held) { results.push({ id, ok: true, gate_held: true, reasons: r.reasons }); held++; }
+        else                  { results.push({ id, ok: true }); ok++; }
+      }
+      catch (e) { results.push({ id, ok: false, error: e.message.slice(0, 200) }); err++; }
+    }
+    return res.json({ ok: true, total: ids.length, applied: ok, gate_held: held, errored: err, action, results });
+  }
+
+  // bad / digital / fix — ONE batched SQL statement, no per-id psql fork storm.
+  const idList = ids.join(',');
+  try {
+    if (action === 'bad') {
+      // Grab local_paths up front so we can quarantine the PNGs after the UPDATE.
+      const rawPaths = psqlQuery(`SELECT json_agg(t) FROM (SELECT id, local_path FROM all_designs WHERE id IN (${idList})) t;`);
+      psqlExecLocal(`UPDATE all_designs SET is_published=FALSE, user_removed=TRUE, web_viewer=FALSE WHERE id IN (${idList});`);
+      const rows = rawPaths && rawPaths.length > 2 ? (JSON.parse(rawPaths) || []) : [];
+      const qDir = path.join(__dirname, 'data', 'generated_cactus_quarantine');
+      for (const row of rows) {
+        if (row.local_path && fs.existsSync(row.local_path)) {
+          try {
+            fs.mkdirSync(qDir, { recursive: true });
+            const dest = path.join(qDir, path.basename(row.local_path));
+            if (!fs.existsSync(dest)) fs.renameSync(row.local_path, dest);
+          } catch (e) { console.warn('[cactus bulk bad] quarantine move failed:', e.message); }
+        }
+      }
+    } else if (action === 'digital') {
+      psqlExecLocal(`UPDATE all_designs
+        SET is_published=FALSE, web_viewer=FALSE, digital_file_at=now(),
+            tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'digital-file') || ARRAY['digital-file']::text[]
+        WHERE id IN (${idList});`);
+    } else if (action === 'fix') {
+      psqlExecLocal(`UPDATE all_designs
+        SET needs_fixing_at=now(),
+            tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'needs-fixing') || ARRAY['needs-fixing']::text[]
+        WHERE id IN (${idList});`);
     }
-    catch (e) { results.push({ id, ok: false, error: e.message.slice(0, 200) }); err++; }
+    // bad + digital both unpublish → drop their SKUs from the live index now.
+    // 'fix' leaves publish state untouched, so it stays in the catalog.
+    const dropped = (action === 'bad' || action === 'digital') ? dropFromIndex(ids) : 0;
+    // Append one decision-log line per id (append-only audit trail).
+    fs.appendFileSync(path.join(__dirname, 'data', 'cactus-decisions.jsonl'),
+      ids.map(id => JSON.stringify({ ts: new Date().toISOString(), id, action })).join('\n') + '\n');
+    res.json({ ok: true, total: ids.length, applied: ids.length, gate_held: 0, errored: 0, action, dropped_from_index: dropped });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
   }
-  res.json({ ok: true, total: ids.length, applied: ok, gate_held: held, errored: err, action, results });
 });
 
 // POST /api/admin/cactus/:id/annotations  body: { boxes:[{x,y,w,h,label}] }
@@ -6804,10 +6880,14 @@ ${(req.query.source === 'all') ? `
       // (lazy-loaded / off-screen) or set via var(--card-bg), which made the
       // parsed URL unreliable and rendered every cell "image unavailable".
       var src = '/designs/img/by-id/' + id;
+      // Room-setting mockup. Admin review-cards carry no .card-room <img>, so we
+      // construct the canonical living-room mockup URL; loadChain falls back to
+      // the flat design tile for any design that has no room render.
+      var roomSrc = '/designs/room/design_' + id + '_living_room.png';
       var t = c.querySelector('.card-title');
       var s = c.querySelector('.card-sku');
       var dig = c.getAttribute('data-dig');
-      return { id:id, src:src,
+      return { id:id, src:src, roomSrc:roomSrc,
         title:(t&&t.textContent||'').trim() || (dig||''),
         sku:(s&&s.textContent||'').trim() };
     }
@@ -6822,6 +6902,12 @@ ${(req.query.source === 'all') ? `
         im.src = src;
       });
     }
+    // Try each src in order, return the first that loads (null if none).
+    function loadChain(srcs){
+      return srcs.reduce(function(p, s){
+        return p.then(function(prev){ return prev || loadImg(s); });
+      }, Promise.resolve(null));
+    }
     function buildContactSheet(ids, btn){
       if (!ids.length) return;
       var infos = ids.map(cardInfo).filter(Boolean);
@@ -6829,11 +6915,11 @@ ${(req.query.source === 'all') ? `
       var origLabel = btn ? btn.textContent : '';
       if (btn) { btn.disabled = true; btn.style.opacity = '.6'; btn.textContent = 'Building… 0/' + infos.length; }
       var n = infos.length;
-      var cols = Math.min(6, Math.max(1, Math.ceil(Math.sqrt(n))));
+      var cols = Math.min(8, Math.max(1, Math.ceil(Math.sqrt(n))));
       var rows = Math.ceil(n / cols);
-      var CELL = 320, LBL = 38, PAD = 12, HEAD = 64;
+      var CELL = 320, LBL = 38, PAD = 12, HEAD = 64, FOOT = 46;
       var W = cols * CELL + (cols + 1) * PAD;
-      var H = HEAD + rows * (CELL + LBL) + (rows + 1) * PAD;
+      var H = HEAD + rows * (CELL + LBL) + (rows + 1) * PAD + FOOT;
       var cv = document.createElement('canvas');
       cv.width = W; cv.height = H;
       var ctx = cv.getContext('2d');
@@ -6851,7 +6937,8 @@ ${(req.query.source === 'all') ? `
       ctx.fillText(meta, W - PAD - 4, HEAD / 2 - 2);
       ctx.textAlign = 'left';
       var loaded = 0;
-      Promise.all(infos.map(function(inf){ return loadImg(inf.src); })).then(function(imgs){
+      // Room-setting first (design shown in a living room), flat tile as fallback.
+      Promise.all(infos.map(function(inf){ return loadChain([inf.roomSrc, inf.src]); })).then(function(imgs){
         for (var i = 0; i < infos.length; i++) {
           var r = Math.floor(i / cols), c = i % cols;
           var x = PAD + c * (CELL + PAD);
@@ -6881,6 +6968,16 @@ ${(req.query.source === 'all') ? `
           if (line !== (infos[i].title + (infos[i].sku ? '  ·  ' + infos[i].sku : '')) && infos[i].title) line = line.replace(/\s*$/, '…');
           ctx.fillText(line, x + 8, y + CELL + LBL / 2);
         }
+        // footer — wallco.ai wordmark centered + design count on the right
+        var fy = H - FOOT / 2;
+        ctx.fillStyle = '#0e0e10'; ctx.fillRect(0, H - FOOT, W, FOOT);
+        ctx.fillStyle = '#d8cfb8'; ctx.font = '600 18px Georgia, serif';
+        ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+        ctx.fillText('wallco.ai', W / 2, fy);
+        ctx.fillStyle = '#7a756b'; ctx.font = '13px system-ui, sans-serif';
+        ctx.textAlign = 'right';
+        ctx.fillText(n + ' design' + (n === 1 ? '' : 's'), W - PAD - 4, fy);
+        ctx.textAlign = 'left';
         cv.toBlob(function(blob){
           if (btn) { btn.disabled = false; btn.style.opacity = '1'; btn.textContent = origLabel; }
           if (!blob) { alert('Contact sheet render failed.'); return; }

← 6ef7cef Add sweep-orphan-rooms.py: remove unreferenced room PNGs (dr  ·  back to Wallco Ai  ·  feat(contact-sheet): room-setting thumbnails + wallco.ai foo 77af6a2 →