← back to Mfr Review Viewer Corruption
viewer: per-vendor Sweep control — GET /api/vendor-plan (read-only analyzer), Stage-for-approval (safe default) + gated Execute-now (typed APPROVE token) modal
5604d8ebf930626caa1518262b58b9645ee3e901 · 2026-08-27 09:37:47 -0700 · vp-dw-commerce
Files touched
M .gitignoreM public/index.htmlM server.js
Diff
commit 5604d8ebf930626caa1518262b58b9645ee3e901
Author: vp-dw-commerce <steve@designerwallcoverings.com>
Date: Thu Aug 27 09:37:47 2026 -0700
viewer: per-vendor Sweep control — GET /api/vendor-plan (read-only analyzer), Stage-for-approval (safe default) + gated Execute-now (typed APPROVE token) modal
---
.gitignore | 1 +
public/index.html | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
server.js | 78 ++++++++++++++++++++++++++++++++++++-
3 files changed, 190 insertions(+), 3 deletions(-)
diff --git a/.gitignore b/.gitignore
index d3f1ef2..3bb429c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ dist/
build/
.next/
data/restore/
+data/plans/
diff --git a/public/index.html b/public/index.html
index 9532985..8e35304 100644
--- a/public/index.html
+++ b/public/index.html
@@ -61,6 +61,29 @@
.badmfr { color:var(--red); text-decoration:line-through; font-variant-numeric:tabular-nums; }
.vendor { color:var(--ink); }
.pattern { color:var(--muted); }
+ .sweep-vendor-btn { font-size:10px; padding:1px 6px; border:1px solid var(--line); background:#12161b;
+ color:var(--teal); border-radius:5px; cursor:pointer; white-space:nowrap; }
+ .sweep-vendor-btn:hover { border-color:var(--teal); }
+ .sweep-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; z-index:50;
+ align-items:center; justify-content:center; }
+ .sweep-overlay.on { display:flex; }
+ .sweep-modal { background:var(--card); border:1px solid var(--line); border-radius:12px; width:min(560px,92vw);
+ max-height:88vh; overflow:auto; padding:18px 20px; }
+ .sweep-modal h3 { margin:0 0 6px; }
+ .sweep-modal .counts { display:grid; grid-template-columns:1fr auto; gap:2px 14px; font-size:13px; margin:10px 0; }
+ .sweep-modal .counts .v { text-align:right; font-variant-numeric:tabular-nums; font-weight:700; }
+ .sweep-modal .actions { display:flex; gap:10px; margin-top:14px; flex-wrap:wrap; }
+ .btn-primary { background:var(--teal); color:#04120f; border:none; padding:9px 14px; border-radius:8px;
+ font-weight:700; cursor:pointer; }
+ .btn-danger { background:#1a1113; color:var(--red); border:1px solid var(--red); padding:9px 14px;
+ border-radius:8px; font-weight:700; cursor:pointer; }
+ .btn-ghost { background:transparent; color:var(--muted); border:1px solid var(--line); padding:9px 14px;
+ border-radius:8px; cursor:pointer; }
+ .sweep-modal .confirm-in { width:100%; padding:7px 9px; margin-top:6px; background:#12161b;
+ border:1px solid var(--red); border-radius:6px; color:var(--red); font-family:ui-monospace,monospace; }
+ .sweep-modal .warn { color:var(--red); font-size:12px; margin-top:8px; }
+ .sweep-modal .sample-skus { font-size:11px; color:var(--muted); font-family:ui-monospace,monospace;
+ max-height:120px; overflow:auto; margin-top:8px; border-top:1px solid var(--line); padding-top:8px; }
.guess-in { width:100%; padding:5px 7px; background:#12161b; border:1px solid var(--line);
border-radius:6px; color:var(--teal); font-weight:600; font-family:ui-monospace,monospace; }
.badges { display:flex; flex-wrap:wrap; gap:5px; }
@@ -191,6 +214,28 @@
<span class="msg" id="stageMsg"></span>
</div>
+<!-- Per-vendor sweep modal (READ-ONLY analyze → Stage / Execute-now) -->
+<div class="sweep-overlay" id="sweepOverlay">
+ <div class="sweep-modal" id="sweepModal">
+ <h3 id="sweepTitle">Sweep vendor</h3>
+ <div id="sweepBody" style="font-size:13px;color:var(--muted)">Running analyzer…</div>
+ <div class="actions" id="sweepActions" style="display:none">
+ <button class="btn-primary" id="sweepStageBtn" title="Draft ONE gated pending-approval memo (SAFE — nothing deleted)">Stage sweep for approval</button>
+ <button class="btn-danger" id="sweepExecBtn" title="DESTRUCTIVE — requires typing the APPROVE token">Execute now…</button>
+ <button class="btn-ghost" id="sweepCloseBtn">Close</button>
+ </div>
+ <div id="sweepExecPanel" style="display:none;margin-top:12px">
+ <div class="warn">⚠ DESTRUCTIVE: this DELETES the broken no-sample masters from FileMaker (WALLPAPER) and writes the real mfr onto the kept master. Each delete is snapshot-first + reversible + ledgered. To proceed, type the exact token:</div>
+ <input class="confirm-in" id="sweepConfirm" placeholder="APPROVE <vendor>" autocomplete="off">
+ <div class="actions">
+ <button class="btn-danger" id="sweepExecGo" disabled>Run executor --apply</button>
+ <button class="btn-ghost" id="sweepExecCancel">Cancel</button>
+ </div>
+ </div>
+ <div class="msg" id="sweepMsg" style="margin-top:10px"></div>
+ </div>
+</div>
+
<script>
// --- creds-in-URL fetch guard (fleet pattern) ------------------------------
// When opened as http://user:pass@host/ the embedded creds poison document.baseURI,
@@ -255,7 +300,13 @@ function renderFacets(){
const active = (state.filters[key]||new Set()).has(v);
const el = document.createElement('div');
el.className='fv'+(active?' active':'');
- el.innerHTML = `<span>${v}</span><span class="c">${c}</span>`;
+ // Vendor rows get a "⚙ Sweep this vendor" control (opens the read-only plan modal).
+ if(key==='vendor' && v && v!=='(none)'){
+ el.innerHTML = `<span>${v}</span><span style="display:flex;gap:6px;align-items:center"><button class="sweep-vendor-btn" title="Run the READ-ONLY DW#==mfr sweep analyzer for this vendor">⚙ Sweep</button><span class="c">${c}</span></span>`;
+ el.querySelector('.sweep-vendor-btn').onclick = (ev)=>{ ev.stopPropagation(); openVendorSweep(v); };
+ } else {
+ el.innerHTML = `<span>${v}</span><span class="c">${c}</span>`;
+ }
el.onclick = ()=>toggleFilter(key,v);
box.appendChild(el);
}
@@ -660,6 +711,67 @@ for(const cb of document.querySelectorAll('#toggles input')){
$('#sort').value = state.sort;
$('#density').value = state.density;
+// ===================== Per-vendor Sweep modal =====================
+let _sweepState = { vendor:'', planFile:null, counts:{} };
+function openVendorSweep(vendor){
+ _sweepState = { vendor, planFile:null, counts:{} };
+ $('#sweepTitle').textContent = 'Sweep vendor — '+vendor;
+ $('#sweepBody').innerHTML = 'Running READ-ONLY analyzer for <b>'+esc(vendor)+'</b>… (enumerates DW#==mfr placeholder SKUs, resolves FileMaker masters). This can take a minute.';
+ $('#sweepActions').style.display='none';
+ $('#sweepExecPanel').style.display='none';
+ $('#sweepMsg').textContent='';
+ $('#sweepOverlay').classList.add('on');
+ fetch('api/vendor-plan?vendor='+encodeURIComponent(vendor)).then(r=>r.json()).then(j=>{
+ if(!j.ok){ $('#sweepBody').innerHTML = '<span style="color:var(--red)">Analyzer error: '+esc(j.error||'unknown')+'</span>'; return; }
+ _sweepState.planFile = j.planFile; _sweepState.counts = j.counts||{};
+ const c = j.counts||{};
+ const row = (k,v)=>`<div>${k}</div><div class="v">${v==null?'—':v}</div>`;
+ let html = '<div class="counts">'+
+ row('DW#==mfr placeholder SKUs', c.skus_found_total)+
+ row('SKUs with deletes', c.skus_with_deletes)+
+ row('Records to delete', c.records_to_delete)+
+ row('Masters to fix (mfr write)', c.masters_to_fix)+
+ row('SKUs skipped by guard', c.skus_skipped_by_guard)+
+ row('SKUs with no FM match', c.skus_no_fm_match)+
+ '</div>';
+ if((c.records_to_delete||0) > 500) html += '<div class="warn">⚠ '+c.records_to_delete+' deletes exceeds the 500 blast cap — Execute-now will require a raised --max.</div>';
+ const samples = (j.plan||[]).slice(0,6).map(e=>`${esc(e.dw_sku)} keep=${esc(e.keepRecordId||'—')} mfr=${esc(e.realMfr||'—')} del=[${(e.deleteRecordIds||[]).map(esc).join(', ')}]`).join('<br>');
+ if(samples) html += '<div class="sample-skus">'+samples+'</div>';
+ $('#sweepBody').innerHTML = html;
+ $('#sweepActions').style.display = (c.records_to_delete||0) ? 'flex' : 'none';
+ if(!(c.records_to_delete||0)) $('#sweepBody').innerHTML += '<div style="margin-top:8px;color:var(--muted)">Nothing to sweep for this vendor.</div>';
+ }).catch(e=>{ $('#sweepBody').innerHTML = '<span style="color:var(--red)">'+esc(e.message)+'</span>'; });
+}
+$('#sweepCloseBtn').onclick = ()=> $('#sweepOverlay').classList.remove('on');
+$('#sweepOverlay').onclick = (e)=>{ if(e.target.id==='sweepOverlay') $('#sweepOverlay').classList.remove('on'); };
+// SAFE DEFAULT — Stage sweep for approval (writes the gated memo, no delete).
+$('#sweepStageBtn').onclick = ()=>{
+ if(!_sweepState.planFile) return;
+ $('#sweepMsg').textContent='Staging gated memo (+ snapshots)…';
+ fetch('api/vendor-stage',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({planFile:_sweepState.planFile})}).then(r=>r.json()).then(j=>{
+ $('#sweepMsg').innerHTML = j.ok
+ ? '<span style="color:var(--teal)">✓ Staged — memo: '+esc(j.memoName||'')+' ('+(j.deletions||0)+' deletes, '+(j.snapshotsOk||0)+' snapshots ok'+(j.snapshotsFailed?', '+j.snapshotsFailed+' FAILED':'')+'). APPROVE it in the pending-approval queue.</span>'
+ : '<span style="color:var(--red)">'+esc(j.error||'stage failed')+'</span>';
+ }).catch(e=>{ $('#sweepMsg').innerHTML='<span style="color:var(--red)">'+esc(e.message)+'</span>'; });
+};
+// GATED — Execute now (requires typed APPROVE token).
+$('#sweepExecBtn').onclick = ()=>{ $('#sweepExecPanel').style.display='block'; $('#sweepConfirm').focus(); };
+$('#sweepExecCancel').onclick = ()=>{ $('#sweepExecPanel').style.display='none'; $('#sweepConfirm').value=''; $('#sweepExecGo').disabled=true; };
+$('#sweepConfirm').oninput = ()=>{ $('#sweepExecGo').disabled = ($('#sweepConfirm').value.trim() !== ('APPROVE '+_sweepState.vendor)); };
+$('#sweepExecGo').onclick = ()=>{
+ const confirm = $('#sweepConfirm').value.trim();
+ if(confirm !== ('APPROVE '+_sweepState.vendor)) return;
+ const nDel = _sweepState.counts.records_to_delete||0;
+ const max = nDel > 500 ? nDel : 500;
+ $('#sweepExecGo').disabled=true; $('#sweepMsg').textContent='Running executor --apply (LIVE destructive)…';
+ fetch('api/vendor-execute',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({vendor:_sweepState.vendor, planFile:_sweepState.planFile, confirm, max})}).then(r=>r.json()).then(j=>{
+ if(j.ok){ $('#sweepMsg').innerHTML='<span style="color:var(--teal)">✓ Executed. Last log lines:</span><div class="sample-skus">'+(j.log||[]).map(esc).join('<br>')+'</div>'; }
+ else { $('#sweepMsg').innerHTML='<span style="color:var(--red)">'+esc(j.error||'execute failed')+'</span>'+(j.log?('<div class="sample-skus">'+(j.log||[]).map(esc).join('<br>')+'</div>'):''); $('#sweepExecGo').disabled=false; }
+ }).catch(e=>{ $('#sweepMsg').innerHTML='<span style="color:var(--red)">'+esc(e.message)+'</span>'; $('#sweepExecGo').disabled=false; });
+};
+
load();
</script>
</body>
diff --git a/server.js b/server.js
index 60fac87..753480e 100644
--- a/server.js
+++ b/server.js
@@ -111,6 +111,79 @@ app.post('/api/rebuild', (_req, res) => {
});
});
+// GET /api/vendor-plan?vendor=<name> — run the READ-ONLY vendor analyzer and return
+// the aggregated plan JSON + counts. Touches NOTHING (psql SELECTs + FM _find reads).
+// Powers the viewer's "⚙ Sweep this vendor" modal. Lazy ES-module import.
+const ANALYZER = path.join(ROOT, 'scripts', 'vendor-sweep-analyze.mjs');
+app.get('/api/vendor-plan', (req, res) => {
+ const vendor = String((req.query && req.query.vendor) || '').trim();
+ if (!vendor) return res.status(400).json({ ok: false, error: 'vendor required' });
+ const args = [ANALYZER, vendor];
+ if (req.query.limit) args.push('--limit', String(parseInt(req.query.limit, 10) || 25));
+ execFile('node', args, { maxBuffer: 1024 * 1024 * 64, timeout: 15 * 60 * 1000 }, (err, stdout, stderr) => {
+ if (err) return res.status(500).json({ ok: false, error: stderr || err.message });
+ // The analyzer wrote data/plans/<slug>-<ISO>.json; find the newest for this vendor.
+ try {
+ const plansDir = path.join(ROOT, 'data', 'plans');
+ const slug = vendor.replace(/[^A-Za-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+ const files = existsSync(plansDir)
+ ? require('node:fs').readdirSync(plansDir).filter((f) => f.startsWith(slug + '-') && f.endsWith('.json'))
+ : [];
+ files.sort();
+ const newest = files[files.length - 1];
+ if (!newest) return res.json({ ok: true, vendor, counts: {}, plan: [], planFile: null, log: (stdout || '').trim().split('\n').slice(-3) });
+ const planPath = path.join(plansDir, newest);
+ const data = JSON.parse(readFileSync(planPath, 'utf8'));
+ res.json({ ok: true, vendor, counts: data.counts || {}, plan: data.plan || [],
+ planFile: path.join('data', 'plans', newest), log: (stdout || '').trim().split('\n').slice(-3) });
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+ });
+});
+
+// POST /api/vendor-stage — write the gated pending-approval memo for a vendor sweep
+// (SAFE DEFAULT). Reads the plan file the analyzer produced + snapshots each delete first,
+// then reuses the SAME /api/stage memo machinery via the deletions[] path. NO delete.
+app.post('/api/vendor-stage', async (req, res) => {
+ const planFile = String((req.body && req.body.planFile) || '').trim();
+ if (!planFile) return res.status(400).json({ ok: false, error: 'planFile required' });
+ const abs = path.isAbsolute(planFile) ? planFile : path.join(ROOT, planFile);
+ if (!existsSync(abs)) return res.status(400).json({ ok: false, error: 'plan file not found' });
+ let plan;
+ try { const d = JSON.parse(readFileSync(abs, 'utf8')); plan = d.plan || d; }
+ catch (e) { return res.status(400).json({ ok: false, error: 'bad plan JSON: ' + e.message }); }
+ // Map the analyzer plan onto the deletions[] shape /api/stage already handles.
+ req.body.deletions = (plan || []).map((e) => ({
+ dw_sku: e.dw_sku, keepRecordId: e.keepRecordId, keepMfr: e.realMfr,
+ keepNote: 'vendor-sweep (mfr-master-sweep)', deleteRecordIds: e.deleteRecordIds || [],
+ }));
+ req.body.selected = [];
+ return stageHandler(req, res); // reuse the exact same gated-memo + snapshot code
+});
+
+// POST /api/vendor-execute — the GATED DESTRUCTIVE "Execute now" path. Requires a typed
+// confirm token `APPROVE <vendor>` in the body. Runs the executor with --apply against the
+// plan file. This is a real canonical FileMaker delete + mfr-write; it is only reachable
+// after the user types the exact APPROVE token in the modal.
+const EXECUTOR = path.join(ROOT, 'scripts', 'execute-sweep.mjs');
+app.post('/api/vendor-execute', (req, res) => {
+ const vendor = String((req.body && req.body.vendor) || '').trim();
+ const planFile = String((req.body && req.body.planFile) || '').trim();
+ const confirm = String((req.body && req.body.confirm) || '').trim();
+ const max = parseInt((req.body && req.body.max), 10) || 500;
+ if (!vendor || !planFile) return res.status(400).json({ ok: false, error: 'vendor + planFile required' });
+ if (confirm !== `APPROVE ${vendor}`) {
+ return res.status(403).json({ ok: false, error: `confirmation mismatch — type exactly: APPROVE ${vendor}` });
+ }
+ const abs = path.isAbsolute(planFile) ? planFile : path.join(ROOT, planFile);
+ if (!existsSync(abs)) return res.status(400).json({ ok: false, error: 'plan file not found' });
+ const args = [EXECUTOR, '--plan', abs, '--apply', '--max', String(max), '--ticket', 'TK-10909', '--agent', 'vp-dw-commerce'];
+ execFile('node', args, { maxBuffer: 1024 * 1024 * 64, timeout: 30 * 60 * 1000, env: process.env },
+ (err, stdout, stderr) => {
+ if (err) return res.status(500).json({ ok: false, error: stderr || err.message, log: (stdout || '').split('\n').slice(-20) });
+ res.json({ ok: true, vendor, applied: true, log: (stdout || '').trim().split('\n').slice(-30) });
+ });
+});
+
// POST /api/stage — THE ONLY WRITE. Gated memo (+ reversible restore snapshots),
// never a canonical/live delete. Accepts:
// selected: [ { dw_sku, bad_mfr, confirmed_real_mfr, vendor, source } ] (mfr-repair rows)
@@ -138,7 +211,7 @@ async function snapshotForRestore(recordId, iso) {
}
}
-app.post('/api/stage', async (req, res) => {
+async function stageHandler(req, res) {
const selected = Array.isArray(req.body && req.body.selected) ? req.body.selected : [];
const deletions = Array.isArray(req.body && req.body.deletions) ? req.body.deletions : [];
if (!selected.length && !deletions.length) {
@@ -245,7 +318,8 @@ app.post('/api/stage', async (req, res) => {
memo: memoPath,
memoName,
});
-});
+}
+app.post('/api/stage', stageHandler);
app.use(express.static(path.join(ROOT, 'public')));
← df9f241 auto-data-snapshot: 2026-08-27T09:33:34 (3 data files) — dat
·
back to Mfr Review Viewer Corruption
·
auto-data-snapshot: 2026-08-27T10:10:28 (1 data files) — dat c59fa7d →