← back to Mfr Review Viewer Corruption

server.js

337 lines

#!/usr/bin/env node
// mfr-review-viewer — authenticated web viewer for SELECTING + REVIEWING broken
// DW catalog SKUs (the "DW# == Mfr SKU" corruption).
//
// READ-ONLY everywhere except ONE gated write: /api/stage drops a memo into
// ~/.claude/yolo-queue/pending-approval/. It NEVER writes to dw_unified,
// FileMaker, or Shopify. Staging is gated-only — it does not apply anything.

const express = require('express');
const { readFileSync, existsSync, writeFileSync, mkdirSync } = require('node:fs');
const { execFile, spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');

const PORT = process.env.PORT || 9786;
const ROOT = __dirname;
const QUEUE_FILE = path.join(ROOT, 'data', 'queue.jsonl');
const LOADER = path.join(ROOT, 'scripts', 'build-queue.mjs');
const ENRICHER = path.join(ROOT, 'scripts', 'enrich-suggestions.mjs');
const PENDING_DIR = path.join(os.homedir(), '.claude', 'yolo-queue', 'pending-approval');
const RESTORE_DIR = path.join(ROOT, 'data', 'restore');

// Basic Auth — admin / DW2024!, overridable via BASIC_AUTH="user:pass".
const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');

const app = express();
app.use(express.json({ limit: '8mb' }));

// ---- Basic Auth gate (covers everything) --------------------------------
app.use((req, res, next) => {
  const hdr = req.headers.authorization || '';
  const [scheme, b64] = hdr.split(' ');
  if (scheme === 'Basic' && b64) {
    const [u, p] = Buffer.from(b64, 'base64').toString().split(':');
    if (u === AUTH_USER && p === AUTH_PASS) return next();
  }
  res.set('WWW-Authenticate', 'Basic realm="mfr-review-viewer"');
  return res.status(401).send('Authentication required');
});

// ---- Data ----------------------------------------------------------------
function loadQueue() {
  if (!existsSync(QUEUE_FILE)) return [];
  const lines = readFileSync(QUEUE_FILE, 'utf8').split('\n').filter((l) => l.trim());
  const rows = [];
  for (const l of lines) { try { rows.push(JSON.parse(l)); } catch { /* skip */ } }
  return rows;
}

// GET /api/queue — the full broken-SKU set + facet counts.
app.get('/api/queue', (_req, res) => {
  const rows = loadQueue();
  const facet = (key) => {
    const m = {};
    for (const r of rows) { const v = (r[key] === '' || r[key] == null) ? '(none)' : r[key]; m[v] = (m[v] || 0) + 1; }
    return m;
  };
  res.json({
    total: rows.length,
    rows,
    facets: {
      vendor: facet('vendor'),
      tier: facet('tier'),
      source: facet('source'),
      confidence: facet('confidence'),
    },
    tiers: facet('tier'),
  });
});

// GET /api/candidates?dw_sku=<sku> — lazy per-chip drill-down. READ-ONLY.
// Returns { filemaker:[...], unified:[...], fmError } for ONE dw_sku: every matching
// FileMaker (FmPro) master + every dw_unified row, so the reviewer can pick the right
// record and read its real mfr code. Never writes anywhere. Lazy ES-module import so a
// FileMaker/creds problem can't break the CJS server startup.
let _candidatesMod = null;
app.get('/api/candidates', async (req, res) => {
  const dwSku = String((req.query && req.query.dw_sku) || '').trim();
  if (!dwSku) return res.status(400).json({ filemaker: [], unified: [], fmError: 'dw_sku required' });
  try {
    if (!_candidatesMod) _candidatesMod = await import('./lib/candidates.mjs');
    const out = await _candidatesMod.candidatesForSku(dwSku);
    res.json(out);
  } catch (e) {
    // Degrade gracefully — never 500 out the whole viewer over a FM/creds hiccup.
    res.json({ filemaker: [], unified: [], fmError: e.message });
  }
});

// GET /api/fm-record-snapshot?recordId=<id> — READ-ONLY full-field dump of ONE
// WALLPAPER master, used to snapshot a record before proposing its deletion. Never
// writes to FileMaker. Lazy ES-module import (a FM/creds hiccup can't break startup).
let _snapMod = null;
app.get('/api/fm-record-snapshot', async (req, res) => {
  const recordId = String((req.query && req.query.recordId) || '').trim();
  if (!recordId) return res.status(400).json({ ok: false, error: 'recordId required' });
  try {
    if (!_snapMod) _snapMod = await import('./lib/fm-record-snapshot.mjs');
    const snap = await _snapMod.snapshotRecord(recordId);
    res.json({ ok: true, ...snap });
  } catch (e) {
    res.json({ ok: false, error: e.message, recordId });
  }
});

// POST /api/rebuild — re-run the loader (behind auth). READ-ONLY vs dw_unified.
app.post('/api/rebuild', (_req, res) => {
  execFile('node', [LOADER], { maxBuffer: 1024 * 1024 * 256 }, (err, stdout, stderr) => {
    if (err) return res.status(500).json({ ok: false, error: stderr || err.message });
    const rows = loadQueue();
    // Auto-refill the FileMaker-derived real-mfr suggestions after a rebuild so the list
    // never goes back to blank. Detached + unref'd so it outlives this request; resumable.
    let enriching = false;
    try { const c = spawn('node', [ENRICHER], { cwd: ROOT, detached: true, stdio: 'ignore' }); c.unref(); enriching = true; } catch { /* best-effort */ }
    res.json({ ok: true, total: rows.length, enriching, log: (stdout || '').trim().split('\n').slice(-6) });
  });
});

// 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)
//   deletions: [ { dw_sku, keepRecordId, keepMfr, keepNote, deleteRecordIds:[...] } ]
//              (per-SKU duplicate-master pruning proposals)
//
// For EACH delete recordId the server FIRST writes a full-field snapshot to
// data/restore/<recordId>-<ISO>.json (READ-ONLY FM read) so an approved delete is
// REVERSIBLE (recreatable). The viewer performs ZERO FileMaker deletes and ZERO
// canonical writes — it only reads FM + writes the restore JSON + the memo. The actual
// delete is a SEPARATE Steve-approved step (there is no auto-executor here).
async function snapshotForRestore(recordId, iso) {
  try {
    if (!_snapMod) _snapMod = await import('./lib/fm-record-snapshot.mjs');
    const snap = await _snapMod.snapshotRecord(recordId);
    if (!existsSync(RESTORE_DIR)) mkdirSync(RESTORE_DIR, { recursive: true });
    const file = `${recordId}-${iso}.json`;
    writeFileSync(path.join(RESTORE_DIR, file), JSON.stringify(snap, null, 2));
    return { recordId, restorePath: path.join('data', 'restore', file), ok: true,
      fieldCount: Object.keys(snap.fieldData || {}).length };
  } catch (e) {
    // A snapshot must NOT be silently skipped — a delete without a saved snapshot is
    // NOT reversible, so we record the failure and refuse to list it as restorable.
    return { recordId, restorePath: null, ok: false, error: e.message };
  }
}

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) {
    return res.status(400).json({ ok: false, error: 'nothing selected (no rows and no deletions)' });
  }

  const iso = new Date().toISOString().replace(/[:.]/g, '-');
  const memoName = `mfr-repair-${iso}.md`;
  const memoPath = path.join(PENDING_DIR, memoName);

  const esc = (s) => String(s == null ? '' : s).replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
  const inline = (s) => String(s == null ? '' : s).replace(/[\r\n]+/g, ' ').trim();

  // ---- snapshot every delete-candidate FIRST (reversibility record) ----------
  // Map: recordId -> { restorePath, ok, error } so the memo can cite each snapshot.
  const snapMap = {};
  let totalDeletes = 0, snapOk = 0, snapFail = 0;
  for (const d of deletions) {
    const ids = Array.isArray(d.deleteRecordIds) ? d.deleteRecordIds.map(String) : [];
    for (const rid of ids) {
      totalDeletes++;
      if (snapMap[rid]) continue;                 // dedupe across SKUs (shouldn't overlap, but safe)
      const s = await snapshotForRestore(rid, iso);
      snapMap[rid] = s;
      s.ok ? snapOk++ : snapFail++;
    }
  }
  const skuWithDeletes = deletions.filter((d) => Array.isArray(d.deleteRecordIds) && d.deleteRecordIds.length).length;

  // ---- build the memo --------------------------------------------------------
  const parts = [];
  parts.push('# MFR SKU Repair — staged for approval');
  parts.push('');
  parts.push(`**Staged:** ${new Date().toISOString()}`);
  if (selected.length) parts.push(`**Mfr-repair rows:** ${selected.length}`);
  if (skuWithDeletes) {
    parts.push(`**Proposes ${totalDeletes} master deletion(s) across ${skuWithDeletes} SKU(s) — REVERSIBLE via saved field snapshots; APPROVE to execute.**`);
    if (snapFail) parts.push(`> ⚠ ${snapFail} of ${totalDeletes} delete snapshot(s) FAILED to capture — those deletes are NOT reversible and must NOT be executed (see per-SKU notes).`);
  }
  parts.push('');
  parts.push('**Staged by mfr-review-viewer — NOT deleted. Nothing was written to dw_unified, FileMaker, or Shopify by the viewer.** The actual delete is a SEPARATE Steve-approved step.');
  parts.push('');

  // -- mfr-repair table (unchanged behavior) --
  if (selected.length) {
    parts.push('## Mfr-code repairs');
    parts.push('');
    parts.push(`${selected.length} broken "DW# == Mfr SKU" catalog row(s) selected for mfr-code repair. Each row's "Confirmed Real Mfr" is the reviewer-edited value from mfr-review-viewer.`);
    parts.push('');
    parts.push('| DW SKU | Current Bad Mfr | Confirmed Real Mfr | Vendor | Source |');
    parts.push('|---|---|---|---|---|');
    for (const r of selected) {
      parts.push(`| ${esc(r.dw_sku)} | ${esc(r.bad_mfr)} | ${esc(r.confirmed_real_mfr)} | ${esc(r.vendor)} | ${esc(r.source)} |`);
    }
    parts.push('');
    parts.push('**APPROVE to apply mfr-code repairs to dw_unified/FileMaker/Shopify — reversible via saved old→new map** (old mfr = "Current Bad Mfr"; new mfr = "Confirmed Real Mfr").');
    parts.push('');
  }

  // -- duplicate-master pruning proposals (KEEP / DELETE per SKU) --
  if (skuWithDeletes) {
    parts.push('## Duplicate-master pruning');
    parts.push('');
    parts.push('Per SKU: KEEP the one correct master, DELETE the duplicate masters. Each DELETE has a saved full-field restore snapshot so an approved delete is reversible (recreatable from the snapshot).');
    parts.push('');
    for (const d of deletions) {
      const ids = Array.isArray(d.deleteRecordIds) ? d.deleteRecordIds.map(String) : [];
      if (!ids.length) continue;
      parts.push(`### ${esc(d.dw_sku)}`);
      const keepBits = [];
      if (d.keepRecordId) keepBits.push(`master ${inline(d.keepRecordId)}`);
      if (d.keepMfr) keepBits.push(`confirmed mfr: ${inline(d.keepMfr)}`);
      if (d.keepNote) keepBits.push(`note: ${inline(d.keepNote)}`);
      parts.push(`KEEP  ${keepBits.length ? keepBits.join('  ') : '(none chosen — reviewer did not mark a keep)'}`);
      const delLine = ids.map((rid) => inline(rid)).join(', ');
      parts.push(`DELETE masters: ${delLine}  (duplicates)`);
      parts.push('  restore snapshots:');
      for (const rid of ids) {
        const s = snapMap[rid] || {};
        if (s.ok) parts.push(`  - ${inline(rid)} → ${s.restorePath}  (${s.fieldCount} fields)`);
        else parts.push(`  - ${inline(rid)} → ⚠ SNAPSHOT FAILED (${inline(s.error) || 'unknown'}) — NOT reversible, do NOT delete`);
      }
      parts.push('');
    }
    parts.push('**APPROVE to execute the DELETEs against FileMaker (WALLPAPER) — each is REVERSIBLE by re-creating the record from its saved data/restore/<recordId>-<ISO>.json snapshot.**');
    parts.push('');
  }

  const memo = parts.join('\n');

  try {
    if (!existsSync(PENDING_DIR)) mkdirSync(PENDING_DIR, { recursive: true });
    writeFileSync(memoPath, memo);
  } catch (e) {
    return res.status(500).json({ ok: false, error: e.message });
  }
  res.json({
    ok: true,
    staged: selected.length,
    deletions: totalDeletes,
    skuWithDeletes,
    snapshotsOk: snapOk,
    snapshotsFailed: snapFail,
    memo: memoPath,
    memoName,
  });
}
app.post('/api/stage', stageHandler);

// Silence the cosmetic /favicon.ico 404 (no-content icon).
app.get('/favicon.ico', (_req, res) => res.status(204).end());

app.use(express.static(path.join(ROOT, 'public')));

app.listen(PORT, '127.0.0.1', () => {
  console.log(`mfr-review-viewer listening on http://127.0.0.1:${PORT}`);
});