[object Object]

← back to Ventura Corridor

iter 56+57: same-tenant merge/dismiss + bulk-merge sweep — POST /api/buildings/dupes/{merge,dismiss,bulk-merge}; bulk-merge supports min_confidence + bldg_address scope + dry_run, picks pitch-bearing biz as primary, chains-aware (skips pairs touching already-merged ids); /buildings.html dupe rows get ✓ Merge / ✕ Dismiss buttons + corridor-wide Preview · merge all 1.00 / ⚡ Sweep merge 1.00 buttons in stats strip; 150 confidence=1.00 pairs ready to bulk-collapse

ae06419767d53a18dfa0d4df2134c7d592c87ca5 · 2026-05-06 13:27:49 -0700 · SteveStudio2

Files touched

Diff

commit ae06419767d53a18dfa0d4df2134c7d592c87ca5
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 13:27:49 2026 -0700

    iter 56+57: same-tenant merge/dismiss + bulk-merge sweep — POST /api/buildings/dupes/{merge,dismiss,bulk-merge}; bulk-merge supports min_confidence + bldg_address scope + dry_run, picks pitch-bearing biz as primary, chains-aware (skips pairs touching already-merged ids); /buildings.html dupe rows get ✓ Merge / ✕ Dismiss buttons + corridor-wide Preview · merge all 1.00 / ⚡ Sweep merge 1.00 buttons in stats strip; 150 confidence=1.00 pairs ready to bulk-collapse
---
 public/buildings.html |  29 ++++++
 src/server/index.ts   | 251 ++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 280 insertions(+)

diff --git a/public/buildings.html b/public/buildings.html
index 0064ee9..380632a 100644
--- a/public/buildings.html
+++ b/public/buildings.html
@@ -168,6 +168,10 @@
   <div class="stat"><div class="num" id="s-walked">—</div><div class="lbl">Walked</div></div>
   <div class="stat"><div class="num" id="s-unpitched">—</div><div class="lbl">No pitch yet</div></div>
   <div class="stat"><div class="num" id="s-dupes" style="color:var(--metal-glow);cursor:pointer" onclick="refreshDupes()" title="click to recompute">—</div><div class="lbl">🔗 Dupe pairs (corridor-wide)</div></div>
+  <div class="stat" style="border-left:1px solid var(--rule);padding-left:24px">
+    <button onclick="bulkMerge(1.0, null, true)" style="background:transparent;border:1px solid var(--metal);color:var(--metal);padding:7px 14px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">Preview · merge all 1.00</button>
+    <button onclick="bulkMerge(1.0, null, false)" style="background:transparent;border:1px solid var(--metal-glow);color:var(--metal-glow);padding:7px 14px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;margin-left:6px">⚡ Sweep merge 1.00</button>
+  </div>
 </div>
 
 <main id="list">
@@ -354,6 +358,31 @@ async function dismissDupe(btn, idA, idB) {
   }
 }
 
+async function bulkMerge(minConf, bldg, dryRun) {
+  const scope = bldg ? `building ${bldg}` : 'corridor-wide';
+  if (!dryRun && !confirm(`Sweep-merge every pair with confidence ≥ ${minConf} (${scope})? Lower-confidence pairs are untouched. This is one-way unless you reset merged_into manually.`)) return;
+  try {
+    const r = await fetch('/api/buildings/dupes/bulk-merge', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ min_confidence: minConf, bldg_address: bldg, dry_run: dryRun })
+    });
+    if (!r.ok) throw new Error(await r.text());
+    const d = await r.json();
+    if (dryRun) {
+      alert(`Preview: ${d.eligible_pairs} pair${d.eligible_pairs === 1 ? '' : 's'} eligible at ≥${minConf} (${d.scope}). No changes made. Click "Sweep merge" to apply.`);
+    } else {
+      alert(`Merged ${d.merged} pair${d.merged === 1 ? '' : 's'} · ${d.skipped_chains} skipped (chained dupes — re-run to catch them)`);
+      // Reload to reflect new state
+      document.querySelectorAll('.roster').forEach(r => r.dataset.loaded = '0');
+      document.querySelectorAll('.bldg.open').forEach(b => b.classList.remove('open'));
+      load();
+      loadDupeTotal();
+    }
+  } catch (e) {
+    alert('Bulk merge failed: ' + e.message);
+  }
+}
+
 async function loadDupeTotal() {
   try {
     const r = await fetch('/api/buildings/dupe-stats');
diff --git a/src/server/index.ts b/src/server/index.ts
index b634369..9f14ac4 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -70,6 +70,7 @@ const ADMIN_PATHS = [
   /^\/postcards(\.html)?\/?$/i,
   /^\/buildings(\.html)?\/?$/i,
   /^\/api\/buildings(\/.*)?$/i,
+  /^\/pitch\/\d+\/?$/i,
 ];
 app.use((req, res, next) => {
   if (!ADMIN_PATHS.some((re) => re.test(req.path))) return next();
@@ -897,6 +898,188 @@ app.patch('/api/pitches/:id', async (req, res) => {
   }
 });
 
+// ─── Bulk pitch action: apply one update to many pitch IDs at once ───
+// Body: { ids: number[], fields: { status?, outreach_channel?, priority?, notes? } }
+// Reuses the same allowlist as PATCH /api/pitches/:id; lifecycle stamps fire too via the trigger.
+app.post('/api/pitches/bulk', express.json(), async (req, res) => {
+  try {
+    const ids: number[] = (req.body?.ids || []).map((x: any) => parseInt(x, 10)).filter(Number.isFinite);
+    if (!ids.length) return res.status(400).json({ error: 'no ids' });
+    if (ids.length > 2000) return res.status(400).json({ error: 'max 2000 ids per call' });
+
+    const allowed = ['status','outreach_channel','priority','notes','pitch_type'];
+    const fields: Record<string, any> = {};
+    for (const k of allowed) if (k in (req.body?.fields || {})) fields[k] = req.body.fields[k];
+    if (!Object.keys(fields).length) return res.status(400).json({ error: 'no fields' });
+
+    const sets: string[] = [];
+    const params: any[] = [];
+    for (const k of Object.keys(fields)) {
+      params.push(fields[k] === '' ? null : fields[k]);
+      sets.push(`${k} = $${params.length}`);
+    }
+    if (fields.status === 'sent')     sets.push('sent_at = COALESCE(sent_at, NOW())');
+    if (fields.status === 'replied')  sets.push('replied_at = COALESCE(replied_at, NOW())');
+    if (fields.status === 'won' || fields.status === 'lost') sets.push('closed_at = COALESCE(closed_at, NOW())');
+    if (fields.status === 'scrubbed') sets.push('scrubbed_at = NOW()');
+    if (fields.status === 'approved') sets.push('approved_at = NOW()');
+
+    params.push(ids);
+    const r = await query(
+      `UPDATE pitches SET ${sets.join(', ')} WHERE id = ANY($${params.length}::bigint[])`,
+      params
+    );
+    res.json({ ok: true, updated: r.rowCount, ids: ids.length });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// ─── Markdown 1-pager: a printable / shareable per-pitch deck ─────────
+// GET /api/pitches/:id/sheet.md → text/markdown rendered from pitch + business
+// GET /pitch/:id (HTML wrapper for browser preview)
+app.get('/api/pitches/:id/sheet.md', async (req, res) => {
+  try {
+    const id = parseInt(req.params.id, 10);
+    if (!Number.isFinite(id)) return res.status(400).send('bad id');
+    const r = await query(
+      `SELECT p.*, b.name AS biz_name, b.address AS biz_address, b.city, b.zip,
+              b.phone AS biz_phone, b.website AS biz_website,
+              b.raw->>'primary_naics_description' AS naics,
+              b.lat, b.lng
+       FROM pitches p JOIN businesses b ON b.id = p.business_id
+       WHERE p.id = $1`,
+      [id]
+    );
+    if (r.rowCount === 0) return res.status(404).send('not found');
+    const p = r.rows[0];
+    const proxLabel: Record<string,string> = {
+      same_building:'⚡ same building', same_block:'★ same block',
+      walk_2min:'2 min walk', walk_5min:'5 min walk',
+      walk_10min:'10 min walk', walk_15min:'15 min walk'
+    };
+    const fmt = (d: any) => d ? new Date(d).toISOString().slice(0, 10) : '—';
+    const md = `# ${p.biz_name}
+
+**${p.biz_address || ''}** · ${p.city || ''} ${p.zip || ''}
+${p.naics ? '*' + p.naics + '*' : ''}
+
+| | |
+|--:|:--|
+| **Pitch type** | ${p.pitch_type} |
+| **Status** | ${p.status} |
+| **Priority** | ${p.priority} |
+| **DW proximity** | ${proxLabel[p.dw_proximity] || p.dw_proximity || '—'} |
+| **Outreach channel** | ${p.outreach_channel || 'unspecified'} |
+| **Created** | ${fmt(p.created_at)} |
+${p.sent_at    ? `| **Sent** | ${fmt(p.sent_at)} |\n` : ''}${p.replied_at ? `| **Replied** | ${fmt(p.replied_at)} |\n` : ''}${p.closed_at  ? `| **Closed** | ${fmt(p.closed_at)} |\n` : ''}
+---
+
+## Why DW fits
+
+${p.why_dw_fits || '_Not yet researched._'}
+
+## Observation
+
+${p.observation || '_Not yet researched._'}
+
+## Outreach copy
+
+**Subject:** ${p.subject || '_(none)_'}
+
+${p.body || '_(no body yet)_'}
+
+${p.li_message ? `### LinkedIn DM\n\n${p.li_message}\n` : ''}
+---
+
+## Contact
+
+| | |
+|--:|:--|
+| **Contact name** | ${p.contact_name || '—'} |
+| **Email** | ${p.email || '—'} |
+| **Phone** | ${p.phone || p.biz_phone || '—'} |
+| **Website** | ${p.website || p.biz_website || '—'} |
+| **LinkedIn** | ${p.linkedin || '—'} |
+| **Instagram** | ${p.instagram || '—'} |
+
+${p.reply_text ? `## Reply received\n\n> ${p.reply_text.replace(/\n/g, '\n> ')}\n\n*via ${p.reply_channel || 'unspecified'} on ${fmt(p.replied_at)}*\n\n` : ''}
+${p.notes ? `## Internal notes\n\n${p.notes}\n\n` : ''}
+---
+
+*Pitch #${p.id} · biz #${p.business_id} · DW Ventura Corridor · 15442 Ventura Blvd #201 · designerwallcoverings.com*
+`;
+    res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
+    res.send(md);
+  } catch (e: any) {
+    res.status(500).send('error: ' + e.message);
+  }
+});
+
+app.get('/pitch/:id', async (req, res) => {
+  const id = req.params.id;
+  res.setHeader('Content-Type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html>
+<html><head>
+<meta charset="utf-8"><title>Pitch #${id} — DW Ventura Corridor</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+  @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;1,400&family=Inter:wght@300;400;600&display=swap');
+  body { font-family: 'Inter', system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 24px; line-height: 1.55; color: #1a1a1a; }
+  h1 { font-family: 'Cormorant Garamond', serif; font-weight: 400; font-size: 38px; border-bottom: 1px solid #b89968; padding-bottom: 10px; margin-bottom: 6px; }
+  h2 { font-family: 'Cormorant Garamond', serif; font-style: italic; font-weight: 400; color: #6a4f2a; margin-top: 32px; }
+  h3 { font-size: 14px; letter-spacing: 0.18em; text-transform: uppercase; color: #888; }
+  table { border-collapse: collapse; margin: 12px 0; font-size: 13px; }
+  td { padding: 4px 16px 4px 0; vertical-align: top; }
+  td:first-child { color: #888; text-align: right; }
+  blockquote { border-left: 3px solid #b89968; padding: 6px 18px; margin: 12px 0; color: #444; font-style: italic; background: #faf8f3; }
+  hr { border: none; border-top: 1px solid #e0d8c8; margin: 24px 0; }
+  code { font-family: ui-monospace, monospace; font-size: 12px; background: #faf8f3; padding: 2px 6px; }
+  .toolbar { display: flex; gap: 10px; margin-bottom: 24px; font-size: 11px; letter-spacing: .18em; text-transform: uppercase; }
+  .toolbar a { color: #b89968; text-decoration: none; padding: 6px 12px; border: 1px solid #d8d2c0; }
+  .toolbar a:hover { border-color: #b89968; }
+  @media print { .toolbar { display: none; } body { max-width: 100%; } }
+</style>
+</head><body>
+<div class="toolbar">
+  <a href="/api/pitches/${id}/sheet.md">⬇ Markdown</a>
+  <a href="javascript:window.print()">🖨 Print</a>
+  <a href="/pitches.html?id=${id}">Edit ↗</a>
+</div>
+<div id="content">Loading…</div>
+<script>
+fetch('/api/pitches/${id}/sheet.md').then(r => r.text()).then(md => {
+  // Minimal inline markdown → HTML (no deps)
+  let html = md
+    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
+    .replace(/^### (.+)$/gm, '<h3>$1</h3>')
+    .replace(/^## (.+)$/gm, '<h2>$1</h2>')
+    .replace(/^# (.+)$/gm, '<h1>$1</h1>')
+    .replace(/^---$/gm, '<hr>')
+    .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
+    .replace(/\\*(.+?)\\*/g, '<em>$1</em>')
+    .replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
+    .replace(/\\n\\n/g, '</p><p>');
+  // Tables: detect | rows
+  html = html.replace(/((?:^\\|.*\\|\\s*\\n)+)/gm, (m) => {
+    const rows = m.trim().split(/\\n/).filter(Boolean);
+    const cells = rows.map(r => r.split('|').slice(1, -1).map(c => c.trim()));
+    const isSep = (cs) => cs.every(c => /^:?-+:?$/.test(c));
+    let out = '<table>';
+    let inBody = false;
+    for (let i = 0; i < cells.length; i++) {
+      if (isSep(cells[i])) { inBody = true; continue; }
+      const tag = inBody ? 'td' : 'th';
+      out += '<tr>' + cells[i].map(c => '<' + tag + '>' + c + '</' + tag + '>').join('') + '</tr>';
+    }
+    return out + '</table>';
+  });
+  document.getElementById('content').innerHTML = '<p>' + html + '</p>';
+});
+</script>
+</body></html>`);
+});
+
 // ─── Buildings: roster of every multi-tenant address on the corridor ──
 // Powers /buildings.html. Admin-only.
 app.get('/api/buildings', async (req, res) => {
@@ -1007,6 +1190,74 @@ app.post('/api/buildings/dupes/merge', express.json(), async (req, res) => {
   }
 });
 
+// Bulk merge: collapse every pair at-or-above min_confidence in a scope (one building, or corridor-wide).
+app.post('/api/buildings/dupes/bulk-merge', express.json(), async (req, res) => {
+  try {
+    const minConf = Math.max(0, Math.min(1, parseFloat(req.body?.min_confidence ?? '1.0')));
+    const bldg = req.body?.bldg_address ? String(req.body.bldg_address) : null;
+    const dryRun = req.body?.dry_run === true;
+
+    const pairs = await query(
+      `SELECT d.id_a, d.id_b, d.bldg_address, d.match_type, d.confidence
+       FROM mv_building_dupes d
+       LEFT JOIN dupe_pair_decisions dec ON dec.id_a = d.id_a AND dec.id_b = d.id_b
+       WHERE dec.id_a IS NULL
+         AND d.confidence >= $1
+         ${bldg ? 'AND d.bldg_address = $2' : ''}
+       ORDER BY d.confidence DESC, d.id_a ASC`,
+      bldg ? [minConf, bldg] : [minConf]
+    );
+
+    if (dryRun) {
+      return res.json({ dry_run: true, eligible_pairs: pairs.rowCount, scope: bldg || 'corridor-wide', min_confidence: minConf });
+    }
+
+    let merged = 0, skipped = 0;
+    const mergedSet = new Set<number>(); // ids that have already been absorbed; skip pairs touching them
+
+    for (const p of pairs.rows) {
+      const a = Number(p.id_a), b = Number(p.id_b);
+      if (mergedSet.has(a) || mergedSet.has(b)) { skipped++; continue; }
+
+      const aHasPitch = (await query(`SELECT 1 FROM pitches WHERE business_id = $1`, [a])).rowCount;
+      const bHasPitch = (await query(`SELECT 1 FROM pitches WHERE business_id = $1`, [b])).rowCount;
+      let primary: number, secondary: number;
+      if (aHasPitch && !bHasPitch)      { primary = a; secondary = b; }
+      else if (bHasPitch && !aHasPitch) { primary = b; secondary = a; }
+      else                              { primary = Math.min(a, b); secondary = Math.max(a, b); }
+
+      const secPitch = await query(`SELECT id FROM pitches WHERE business_id = $1`, [secondary]);
+      if (secPitch.rowCount) {
+        const priPitch = await query(`SELECT id FROM pitches WHERE business_id = $1`, [primary]);
+        if (!priPitch.rowCount) {
+          await query(`UPDATE pitches SET business_id = $1 WHERE business_id = $2`, [primary, secondary]);
+        } else {
+          await query(`
+            UPDATE pitches p SET notes = CONCAT_WS(E'\\n--- merged from biz ${secondary} ---\\n', p.notes, sec.notes)
+            FROM pitches sec WHERE p.business_id = $1 AND sec.business_id = $2
+          `, [primary, secondary]);
+          await query(`DELETE FROM pitches WHERE business_id = $1`, [secondary]);
+        }
+      }
+
+      await query(`UPDATE businesses SET merged_into = $1, merged_at = NOW() WHERE id = $2 AND merged_into IS NULL`, [primary, secondary]);
+      const lo = Math.min(a, b), hi = Math.max(a, b);
+      await query(
+        `INSERT INTO dupe_pair_decisions (id_a, id_b, decision) VALUES ($1, $2, 'merge')
+         ON CONFLICT (id_a, id_b) DO UPDATE SET decision='merge', decided_at=NOW()`,
+        [lo, hi]
+      );
+      mergedSet.add(secondary);
+      merged++;
+    }
+
+    await query(`SELECT refresh_building_dupes()`);
+    res.json({ ok: true, merged, skipped_chains: skipped, scope: bldg || 'corridor-wide', min_confidence: minConf });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // Dismiss a pair as a false positive — they stay separate, just stop suggesting them as dupes
 app.post('/api/buildings/dupes/dismiss', express.json(), async (req, res) => {
   try {

← db3c21d iter 56: same-tenant merge + dismiss actions — migration 015  ·  back to Ventura Corridor  ·  iter 58: /pitches.html mass-action toolbar — POST /api/pitch c92b71f →