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