[object Object]

← 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

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 &lt;vendor&gt;" 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 →