[object Object]

← back to Rentv 2026

auto-save: 2026-07-30T11:17:19 (4 files) — package.json server.js public/admin/pr-intelligence/ src/pr/index.js

7a3217d0f2493a1201092ea5affff068c575dae3 · 2026-07-30 11:17:22 -0700 · Steve Abrams

Files touched

Diff

commit 7a3217d0f2493a1201092ea5affff068c575dae3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 11:17:22 2026 -0700

    auto-save: 2026-07-30T11:17:19 (4 files) — package.json server.js public/admin/pr-intelligence/ src/pr/index.js
---
 package.json                                    |   2 +-
 public/admin/pr-intelligence/index.html         |  95 ++++++
 public/admin/pr-intelligence/organizations.html | 210 ++++++++++++
 public/admin/pr-intelligence/pr-shared.css      | 124 +++++++
 public/admin/pr-intelligence/pr-shared.js       | 220 ++++++++++++
 server.js                                       |   5 +
 src/pr/index.js                                 | 426 ++++++++++++++++++++++++
 7 files changed, 1081 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 9c890e7e..977adb6d 100644
--- a/package.json
+++ b/package.json
@@ -1 +1 @@
-{"name":"rentv","version":"0.10.0","private":true,"main":"server.js","scripts":{"start":"node server.js","pull":"node scripts/pull-news.mjs"},"dependencies":{"express":"^4.21.2","pg":"^8.22.0"}}
\ No newline at end of file
+{"name":"rentv","version":"0.10.0","private":true,"main":"server.js","scripts":{"start":"node server.js","pull":"node scripts/pull-news.mjs","pr:migrate":"node -e \"require('./src/pr/db').runMigrations({log:console.log}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(e.message);process.exit(1)})\"","pr:worker":"node src/pr/worker.js","pr:seed:ca":"node src/pr/seed/ca-seed.js","test":"node --test test/pr/"},"dependencies":{"express":"^4.21.2","pg":"^8.22.0"}}
\ No newline at end of file
diff --git a/public/admin/pr-intelligence/index.html b/public/admin/pr-intelligence/index.html
new file mode 100644
index 00000000..d3c41bab
--- /dev/null
+++ b/public/admin/pr-intelligence/index.html
@@ -0,0 +1,95 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Dashboard</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('', 'Dashboard', 'California-first CRE PR research, review, letters, and replies. Arizona unlocks after the CA quality gate.');
+  c.innerHTML = '<div class="empty"><span class="spin"></span> Loading…</div>';
+  let d;
+  try { d = await PR.api('/dashboard'); }
+  catch (e) {
+    c.innerHTML = `<div class="panel"><h3>Database unavailable</h3><p>${PR.esc(e.message)}</p>
+      <p>Create the database once (<code>createdb rentv_pr</code>) then run <code>npm run pr:migrate</code>.</p></div>`;
+    return;
+  }
+  const gate = d.california.gate || {};
+  c.innerHTML = `
+    <div class="kpis">
+      <div class="kpi"><b>${d.california.completion_pct}%</b><span>CA coverage (metro×category cells: ${d.california.cells_covered}/${d.california.cells_total})</span></div>
+      <div class="kpi ${d.arizona.unlocked ? 'ok' : 'warn'}"><b>${d.arizona.unlocked ? 'ACTIVE' : '🔒 LOCKED'}</b><span>Arizona phase (${d.arizona.organizations} orgs)</span></div>
+      <div class="kpi"><b>${d.organizations.total.toLocaleString()}</b><span>Organizations (${d.organizations.verified} verified · ${d.organizations.unreviewed} unreviewed)</span></div>
+      <div class="kpi"><b>${d.people.total.toLocaleString()}</b><span>People (${d.people.verified} verified · ${d.people.unverified} unverified)</span></div>
+      <div class="kpi ${d.people.stale ? 'warn' : ''}"><b>${d.people.stale}</b><span>Stale contacts</span></div>
+      <div class="kpi"><b>${d.orgs_without_pr_contact}</b><span>Orgs without a PR contact</span></div>
+      <div class="kpi ${d.drafts_awaiting_review ? 'warn' : ''}"><b>${d.drafts_awaiting_review}</b><span>Drafts awaiting review</span></div>
+      <div class="kpi ${d.replies_requiring_action ? 'bad' : ''}"><b>${d.replies_requiring_action}</b><span>Replies requiring action</span></div>
+      <div class="kpi ${d.followups_due ? 'warn' : ''}"><b>${d.followups_due}</b><span>Follow-ups due</span></div>
+      <div class="kpi"><b>${d.organizations.new7 + d.people.new7}</b><span>New discoveries (7d)</span></div>
+    </div>
+
+    <div class="cols3">
+      <div class="panel"><h3>California quality gate ${gate.passed ? PR.badge('PASSED', 'green') : PR.badge('not passed', 'amber')}</h3>
+        <div id="gate-checks">${gate.checks ? renderGate(gate.checks) : '<div class="empty">Not yet evaluated — run the check from Settings.</div>'}</div>
+        <p style="margin:10px 0 0"><a class="btn sm" href="/admin/pr-intelligence/settings">Gate details & Arizona unlock →</a></p>
+      </div>
+      <div class="panel"><h3>Organizations by category</h3>
+        ${d.by_category.slice(0, 14).map((x) => bar(x.organization_type, x.n, d.by_category[0]?.n || 1)).join('') || '<div class="empty">No data yet</div>'}
+      </div>
+      <div class="panel"><h3>Contacts by role · metro</h3>
+        ${d.by_role.slice(0, 7).map((x) => bar(x.department, x.n, d.by_role[0]?.n || 1)).join('') || '<div class="empty">No contacts yet</div>'}
+        <hr style="border:none;border-top:1px solid var(--line);margin:10px 0">
+        ${d.by_metro.slice(0, 7).map((x) => bar(x.metro, x.n, d.by_metro[0]?.n || 1)).join('') || '<div class="empty">No metro data</div>'}
+      </div>
+    </div>
+
+    <div class="cols3">
+      <div class="panel"><h3>Research-run health</h3>
+        <div class="kv">
+          <dt>Running</dt><dd>${d.run_health.running}</dd><dt>Queued</dt><dd>${d.run_health.queued} runs · ${d.run_health.queued_jobs} jobs</dd>
+          <dt>Paused</dt><dd>${d.run_health.paused}</dd><dt>Failed</dt><dd>${d.run_health.failed} runs · ${d.run_health.failed_jobs} jobs</dd>
+          <dt>Completed</dt><dd>${d.run_health.completed}</dd>
+        </div>
+        <a class="btn sm" href="/admin/pr-intelligence/runs">Runs & jobs →</a>
+      </div>
+      <div class="panel"><h3>Source adapters</h3>
+        ${d.adapters.map((a) => `<div class="gatechk"><span class="s">${a.configured ? '🟢' : '⚪️'}</span>
+          <b>${a.name}</b> <span style="color:var(--muted)">${a.kind}${a.missing_config.length ? ' · needs: ' + PR.esc(a.missing_config.join(', ')) : ''}</span></div>`).join('')}
+      </div>
+      <div class="panel"><h3>PR agencies & CRE clients</h3>
+        ${d.pr_agencies.length ? d.pr_agencies.map((a) => `<div class="gatechk"><b>${PR.esc(a.display_name)}</b><span style="color:var(--muted)">${a.clients} linked client(s)</span></div>`).join('') : '<div class="empty">No agencies yet</div>'}
+      </div>
+    </div>
+
+    <div class="panel"><h3>California coverage gaps ${d.coverage_gaps ? '<span style="color:var(--muted);font-weight:400;font-size:12px">generated ' + PR.date(d.coverage_gaps.generated_at) + '</span>' : ''}</h3>
+      ${d.coverage_gaps ? `<p style="font-size:13px">${d.coverage_gaps.gap_count} empty metro×category cells remain. Sample:</p>
+        <div>${d.coverage_gaps.sample.map((g) => PR.badge(g.metro + ' · ' + g.type, 'amber')).join(' ')}</div>`
+      : '<div class="empty">No coverage report yet — queue <code>generate-coverage-report</code> from Runs & Jobs.</div>'}
+    </div>`;
+
+  function bar(label, n, max) {
+    return `<div style="display:flex;align-items:center;gap:8px;padding:2px 0;font-size:12.5px">
+      <span style="width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${PR.esc(label)}</span>
+      <span class="bar" style="flex:1;height:8px;background:#eef1f5;border-radius:4px;overflow:hidden"><i style="display:block;height:100%;width:${Math.round(n / max * 100)}%;background:var(--accent)"></i></span>
+      <b style="width:40px;text-align:right">${n}</b></div>`;
+  }
+  function renderGate(checks) {
+    const item = (ok, label, extra) => `<div class="gatechk"><span class="s">${ok ? '✅' : '❌'}</span><span>${label}${extra ? ' — ' + extra : ''}</span></div>`;
+    return [
+      item(checks.pipeline_resumable, 'Pipeline resumable (completed + checkpointed runs)'),
+      item(checks.dedupe_working, 'Deduplication exercised'),
+      item(checks.org_completeness && checks.org_completeness.pass, '≥80% reviewed CA orgs complete', checks.org_completeness ? checks.org_completeness.pct + '% of ' + checks.org_completeness.reviewed : ''),
+      item(checks.contact_coverage && checks.contact_coverage.pass, '≥70% high-priority orgs have a contact', checks.contact_coverage ? checks.contact_coverage.pct + '% of ' + checks.contact_coverage.high_priority : ''),
+      item(checks.workflows_exercised, 'Review / letters / outreach / opt-out flows exercised'),
+      item(checks.no_mislabeled_inferred, 'No inferred contact mislabeled as sendable'),
+    ].join('');
+  }
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/organizations.html b/public/admin/pr-intelligence/organizations.html
new file mode 100644
index 00000000..0c0b7eee
--- /dev/null
+++ b/public/admin/pr-intelligence/organizations.html
@@ -0,0 +1,210 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Organizations</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('organizations', 'Organizations', 'Filter, sort, review, merge, enroll, export. Click a row for the full profile with source evidence.');
+  const meta = await PR.api('/meta');
+  const CA = meta.states.find((s) => s.key === 'CA'), AZ = meta.states.find((s) => s.key === 'AZ');
+
+  let sort = 'priority', dir = 'desc', offset = 0; const limit = 50;
+  const { bar, state } = PR.filters([
+    { key: 'q', placeholder: 'Search name / domain…' },
+    { key: 'state', type: 'select', options: [['', 'State: all'], ['CA', 'California'], ['AZ', 'Arizona']] },
+    { key: 'metro', type: 'select', options: [['', 'Metro: all'], ...CA.metros.map((m) => [m.key, 'CA · ' + m.label]), ...AZ.metros.map((m) => [m.key, 'AZ · ' + m.label])] },
+    { key: 'type', type: 'select', options: [['', 'Category: all'], ...meta.org_types.map((t) => [t.key, t.label])] },
+    { key: 'asset_class', type: 'select', options: [['', 'Asset class: all'], ...meta.asset_classes.map((a) => [a, a.replace(/_/g, ' ')])] },
+    { key: 'status', type: 'select', options: [['', 'Status: all']].concat('discovered,needs_review,verified,incomplete,duplicate,archived,ready_for_outreach,contacted,active_relationship'.split(',').map((s) => [s, s.replace(/_/g, ' ')])) },
+    { key: 'min_confidence', type: 'select', options: [['', 'Confidence: any'], ['40', '≥40'], ['70', '≥70']] },
+    { key: 'min_priority', type: 'select', options: [['', 'Priority: any'], ['40', '≥40'], ['60', '≥60'], ['80', '≥80']] },
+    { key: 'no_pr_contact', type: 'select', options: [['', 'PR contact: any'], ['1', 'No PR contact yet']] },
+  ], () => { offset = 0; load(); });
+  c.appendChild(bar);
+
+  const actions = document.createElement('div'); actions.className = 'filters';
+  actions.innerHTML = `
+    <button class="btn" id="add-org">+ Add organization</button>
+    <button class="btn" id="verify-sel" disabled>Queue verify</button>
+    <button class="btn" id="tag-sel" disabled>Bulk tag</button>
+    <button class="btn" id="campaign-sel" disabled>Add contacts to campaign</button>
+    <a class="btn" id="export" href="#">⬇ Export CSV (current filters)</a>
+    <span id="selcount" style="color:var(--muted);font-size:12px"></span>`;
+  c.appendChild(actions);
+
+  const selected = new Set();
+  const tbl = PR.table([
+    { key: '_sel', label: '', render: (r) => `<input type="checkbox" data-sel="${r.id}" ${selected.has(r.id) ? 'checked' : ''}>` },
+    { key: 'name', label: 'Organization', render: (r) => `<b>${PR.esc(r.display_name)}</b><br><span style="color:var(--muted);font-size:12px">${PR.esc(r.domain || '')}</span>` },
+    { key: 'type', label: 'Category', render: (r) => PR.badge(r.organization_type, 'blue') },
+    { key: '_geo', label: 'Metros', render: (r) => (r.metros || []).map((m) => PR.badge(m)).join(' ') || (r.state_presence || []).join(',') },
+    { key: '_pr', label: 'PR', render: (r) => r.internal_pr_status === 'has_internal_pr' ? PR.badge('internal PR', 'green') : r.general_press_email ? PR.badge('press email', 'blue') : PR.badge(r.internal_pr_status, 'gray') },
+    { key: '_ppl', label: '👤', render: (r) => String(r.people_count) },
+    { key: 'confidence', label: 'Conf', render: (r) => PR.score(r.confidence_score) },
+    { key: 'priority', label: 'Priority', render: (r) => PR.score(r.priority_score) },
+    { key: 'status', label: 'Status', render: (r) => PR.statusBadge(r.lifecycle_status) },
+    { key: 'verified', label: 'Verified', render: (r) => PR.dateShort(r.last_verified_at) },
+  ], {
+    onSort: (s, d2) => { sort = s; dir = d2; load(); },
+    onRow: (r) => openOrg(r.id),
+  });
+  c.appendChild(tbl.el);
+  const pagerBox = document.createElement('div'); c.appendChild(pagerBox);
+
+  async function load() {
+    const q = new URLSearchParams({ ...Object.fromEntries(Object.entries(state).filter(([, v]) => v)), sort, dir, limit, offset });
+    const j = await PR.api('/organizations?' + q);
+    tbl.render(j.rows);
+    pagerBox.innerHTML = '';
+    pagerBox.appendChild(PR.pager(j.total, limit, offset, (o) => { offset = o; load(); }));
+    document.getElementById('export').href = '/api/pr/export/organizations?' + q;
+    tbl.el.querySelectorAll('[data-sel]').forEach((cb) => cb.addEventListener('change', () => {
+      const id = Number(cb.dataset.sel);
+      cb.checked ? selected.add(id) : selected.delete(id);
+      syncSel();
+    }));
+  }
+  function syncSel() {
+    ['verify-sel', 'tag-sel', 'campaign-sel'].forEach((id) => document.getElementById(id).disabled = !selected.size);
+    document.getElementById('selcount').textContent = selected.size ? selected.size + ' selected' : '';
+  }
+
+  const drawer = PR.drawer();
+  async function openOrg(id) {
+    const o = await PR.api('/organizations/' + id);
+    const comp = (o.score_components || {});
+    const body = drawer.open(`${PR.esc(o.display_name)} ${PR.statusBadge(o.lifecycle_status)}`, `
+      <div class="kv">
+        <dt>Legal name</dt><dd>${PR.esc(o.legal_name || '—')}</dd>
+        <dt>Website</dt><dd>${o.website_url ? `<a href="${PR.esc(o.website_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(o.website_url)}</a>` : '—'}</dd>
+        <dt>LinkedIn</dt><dd>${o.linkedin_company_url ? `<a href="${PR.esc(o.linkedin_company_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(o.linkedin_company_url)}</a> ${PR.verifBadge(o.linkedin_status)}` : '—'}</dd>
+        <dt>Category</dt><dd>${PR.badge(o.organization_type, 'blue')} ${PR.esc(o.organization_subtype || '')}</dd>
+        <dt>Asset classes</dt><dd>${(o.asset_classes || []).map((a) => PR.badge(a)).join(' ') || '—'}</dd>
+        <dt>Geography</dt><dd>${(o.state_presence || []).join(', ')} · ${(o.metros || []).join(', ') || 'no metro'} · ${(o.counties || []).join(', ') || 'no county'}</dd>
+        <dt>HQ</dt><dd>${PR.esc(o.headquarters || '—')}</dd>
+        <dt>Internal PR</dt><dd>${PR.badge(o.internal_pr_status, o.internal_pr_status === 'has_internal_pr' ? 'green' : 'gray')}</dd>
+        <dt>Press page</dt><dd>${o.press_page_url ? `<a href="${PR.esc(o.press_page_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(o.press_page_url.slice(0, 70))}</a>` : '—'}</dd>
+        <dt>Press email</dt><dd>${PR.esc(o.general_press_email || '—')} ${o.general_press_phone ? '· ' + PR.esc(o.general_press_phone) : ''}</dd>
+        <dt>Contact form</dt><dd>${o.contact_form_url ? `<a href="${PR.esc(o.contact_form_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(o.contact_form_url.slice(0, 70))}</a>` : '—'}</dd>
+        <dt>Scores</dt><dd>Confidence ${PR.score(o.confidence_score)} · Priority ${PR.score(o.priority_score)}
+          <details style="margin-top:4px"><summary style="cursor:pointer;font-size:12px;color:var(--muted)">score components</summary>
+          <pre style="font-size:11px;background:var(--bg);padding:8px;border-radius:6px;overflow:auto">${PR.esc(JSON.stringify(comp, null, 1))}</pre></details></dd>
+        <dt>Verification</dt><dd>${PR.verifBadge(o.verification_status)} · last verified ${PR.date(o.last_verified_at)}</dd>
+        <dt>Created</dt><dd title="${PR.esc(o.created_at)}">🕓 ${PR.date(o.created_at)}</dd>
+      </div>
+      <div class="filters">
+        <button class="btn sm" id="d-verify">Queue website verify</button>
+        <button class="btn sm" id="d-people">Queue person discovery</button>
+        <button class="btn sm" id="d-matches">Find duplicates</button>
+        <a class="btn sm" href="/admin/pr-intelligence/review?kind=organizations&focus=${o.id}">Open in review</a>
+      </div>
+      <div id="d-matches-out"></div>
+      <h3 style="margin:14px 0 8px">People (${o.people.length})</h3>
+      ${o.people.map((p) => `<div class="gatechk"><b>${PR.esc(p.full_name)}</b>
+        <span style="color:var(--muted)">${PR.esc(p.exact_title || '')}</span>
+        ${PR.verifBadge(p.email_verification_status)} ${PR.statusBadge(p.lifecycle_status)}
+        <a href="/admin/pr-intelligence/people?id=${p.id}" style="margin-left:auto">open →</a></div>`).join('') || '<div class="empty">No people yet</div>'}
+      <h3 style="margin:14px 0 8px">Agency / parent relationships</h3>
+      ${o.relationships.map((r) => `<div class="gatechk">${PR.badge(r.relationship_type, 'purple')}
+        <span>${PR.esc(r.source_name_org)} → ${PR.esc(r.target_name_org)}</span>
+        ${PR.badge(r.current_status, r.current_status === 'current' ? 'green' : 'gray')}<span style="color:var(--muted)">conf ${r.confidence}</span></div>`).join('') || '<div class="empty">None recorded</div>'}
+      <h3 style="margin:14px 0 8px">Source evidence</h3>
+      ${PR.evidenceHtml(o.evidence, { addToLetter: true })}
+      <h3 style="margin:14px 0 8px">Outreach history</h3>
+      ${o.outreach.map((m) => `<div class="gatechk">${PR.statusBadge(m.status)} <span>${PR.esc(m.subject || '(no subject)')}</span>
+        <span style="color:var(--muted);margin-left:auto">${PR.dateShort(m.sent_at || m.drafted_at)}</span></div>`).join('') || '<div class="empty">No outreach yet</div>'}
+      <h3 style="margin:14px 0 8px">Recent activity</h3>
+      ${o.recent_activity.slice(0, 12).map((a) => `<div class="gatechk"><span style="color:var(--muted)">${PR.dateShort(a.created_at)}</span>
+        <span>${PR.esc(a.activity)}</span><span style="color:var(--muted)">${PR.esc(a.actor || '')}</span></div>`).join('')}`);
+
+    body.querySelector('#d-verify').addEventListener('click', () =>
+      PR.guard(() => PR.api('/jobs', { method: 'POST', body: { job_type: 'verify-organization', payload: { organization_id: o.id } } }), 'Verify job queued'));
+    body.querySelector('#d-people').addEventListener('click', () =>
+      PR.guard(() => PR.api('/jobs', { method: 'POST', body: { job_type: 'discover-people', payload: { organization_id: o.id, team_pages: [] } } }), 'Person-discovery job queued'));
+    body.querySelector('#d-matches').addEventListener('click', async () => {
+      const j = await PR.guard(() => PR.api('/organizations/' + o.id + '/matches'));
+      const out = body.querySelector('#d-matches-out');
+      out.innerHTML = j.matches.length ? j.matches.map((m) => `
+        <div class="evi"><b>${PR.esc(m.org.display_name)}</b> ${PR.badge(m.verdict, m.verdict === 'exact' ? 'red' : 'amber')} score ${m.score}
+          <div class="x">${m.reasons.map(PR.esc).join(' · ')}</div>
+          <button class="btn sm" data-merge="${m.org.id}">Merge that one into this</button></div>`).join('')
+        : '<div class="empty">No duplicate candidates</div>';
+      out.querySelectorAll('[data-merge]').forEach((b) => b.addEventListener('click', async () => {
+        if (!confirm('Merge #' + b.dataset.merge + ' into "' + o.display_name + '"? The other record becomes a duplicate pointer.')) return;
+        await PR.guard(() => PR.api('/organizations/' + o.id + '/merge', { method: 'POST', body: { duplicate_id: Number(b.dataset.merge) } }), 'Merged');
+        drawer.close(); load();
+      }));
+    });
+    body.querySelectorAll('[data-addletter]').forEach((a) => a.addEventListener('click', (e) => {
+      e.preventDefault();
+      localStorage.setItem('pr-letter-clip', JSON.stringify({ org_id: o.id, org_name: o.display_name, source_id: Number(a.dataset.addletter), excerpt: a.dataset.excerpt }));
+      PR.toast('Added to letter clipboard — open the Letter Builder to insert it');
+    }));
+  }
+
+  // toolbar actions
+  document.getElementById('add-org').addEventListener('click', () => {
+    const body = drawer.open('Add organization (manual research entry)', `
+      <div class="form">
+        <div><label>Display name *</label><input id="f-name"></div>
+        <div class="frow"><div><label>Website URL</label><input id="f-web" placeholder="https://…"></div>
+          <div><label>Category</label><select id="f-type">${meta.org_types.map((t) => `<option value="${t.key}">${t.label}</option>`).join('')}</select></div></div>
+        <div class="frow"><div><label>State</label><select id="f-state"><option>CA</option><option>AZ</option></select></div>
+          <div><label>Metro</label><select id="f-metro">${CA.metros.map((m) => `<option value="${m.key}">${m.label}</option>`).join('')}</select></div></div>
+        <div><label>Verification URL (the page you checked) *</label><input id="f-src" placeholder="https://…"></div>
+        <div><label>Evidence excerpt</label><textarea id="f-exc" placeholder="Short quote from the page backing this entry"></textarea></div>
+        <button class="btn primary" id="f-save">Save organization</button>
+      </div>`);
+    body.querySelector('#f-save').addEventListener('click', async () => {
+      const name = body.querySelector('#f-name').value.trim();
+      const src = body.querySelector('#f-src').value.trim();
+      if (!name) return PR.toast('Name required', true);
+      if (!src) return PR.toast('A verification URL is required for manual entries', true);
+      await PR.guard(() => PR.api('/organizations', { method: 'POST', body: {
+        display_name: name, website_url: body.querySelector('#f-web').value.trim() || null,
+        organization_type: body.querySelector('#f-type').value,
+        state_presence: [body.querySelector('#f-state').value], metros: [body.querySelector('#f-metro').value],
+        evidence: { source: { source_type: 'manual', source_name: 'Manual research entry', url: src, short_excerpt: body.querySelector('#f-exc').value.trim() || null, is_primary_source: false, adapter: 'manual' }, fields: { organization: 85 } },
+      } }), 'Organization saved');
+      drawer.close(); load();
+    });
+  });
+  document.getElementById('verify-sel').addEventListener('click', async () => {
+    for (const id of selected) await PR.api('/jobs', { method: 'POST', body: { job_type: 'verify-organization', payload: { organization_id: id } } });
+    PR.toast(selected.size + ' verify job(s) queued'); selected.clear(); syncSel(); load();
+  });
+  document.getElementById('tag-sel').addEventListener('click', async () => {
+    const tag = prompt('Tag to add to ' + selected.size + ' organization(s):'); if (!tag) return;
+    for (const id of selected) {
+      const o = await PR.api('/organizations/' + id);
+      await PR.api('/organizations/' + id, { method: 'PUT', body: { tags: [...new Set([...(o.tags || []), tag])] } });
+    }
+    PR.toast('Tagged'); selected.clear(); syncSel(); load();
+  });
+  document.getElementById('campaign-sel').addEventListener('click', async () => {
+    const camps = (await PR.api('/campaigns')).rows;
+    if (!camps.length) return PR.toast('Create a campaign first (Campaigns page)', true);
+    const cid = prompt('Campaign id to enroll each org\'s PEOPLE into:\n' + camps.map((x) => `#${x.id} ${x.name}`).join('\n'));
+    if (!cid) return;
+    let n = 0;
+    for (const id of selected) {
+      const o = await PR.api('/organizations/' + id);
+      const ids = o.people.map((p) => p.id);
+      if (ids.length) { const r = await PR.api('/campaigns/' + cid + '/enroll', { method: 'POST', body: { person_ids: ids } }); n += r.added.length; }
+    }
+    PR.toast(n + ' people enrolled');
+  });
+
+  // deep link ?id=
+  const deepId = new URLSearchParams(location.search).get('id');
+  await load();
+  if (deepId) openOrg(Number(deepId));
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/pr-shared.css b/public/admin/pr-intelligence/pr-shared.css
new file mode 100644
index 00000000..46430e70
--- /dev/null
+++ b/public/admin/pr-intelligence/pr-shared.css
@@ -0,0 +1,124 @@
+/* CRE PR Intelligence — shared workspace styles (matches the RENTV internal-shell language) */
+:root { --ink:#0d1b2a; --muted:#5b6b7b; --line:#e4e9ef; --bg:#f6f8fb; --accent:#0a6cff; --ok:#0f8a4d; --warn:#b7791f; --bad:#c0392b; --purple:#6b46c1; }
+* { box-sizing:border-box; }
+body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color:var(--ink); background:var(--bg); }
+a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
+
+/* Frame: top bar + left nav + content */
+.pr-top { display:flex; align-items:center; gap:14px; padding:14px 22px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:30; }
+.pr-top h1 { margin:0; font-size:17px; } .pr-top h1 a { color:inherit; }
+.pr-top .tag { font-size:10px; text-transform:uppercase; letter-spacing:.12em; color:#fff; background:var(--ink); padding:3px 8px; border-radius:999px; }
+.pr-top .gsearch { margin-left:auto; position:relative; }
+.pr-top .gsearch input { width:340px; padding:8px 12px; border:1px solid var(--line); border-radius:8px; font-size:13px; }
+.gsearch .results { position:absolute; right:0; top:40px; width:420px; max-height:60vh; overflow:auto; background:#fff; border:1px solid var(--line); border-radius:10px; box-shadow:0 12px 32px rgba(13,27,42,.14); display:none; }
+.gsearch .results.open { display:block; }
+.gsearch .results .g { padding:8px 12px 2px; font-size:10px; text-transform:uppercase; letter-spacing:.1em; color:var(--muted); }
+.gsearch .results a { display:block; padding:8px 12px; color:inherit; border-bottom:1px solid var(--bg); }
+.gsearch .results a:hover { background:var(--bg); text-decoration:none; }
+.pr-wrap { display:flex; min-height:calc(100vh - 57px); }
+.pr-nav { width:198px; flex:0 0 198px; background:#fff; border-right:1px solid var(--line); padding:14px 0; }
+.pr-nav a { display:flex; gap:9px; align-items:center; padding:8px 18px; color:var(--ink); font-size:13.5px; }
+.pr-nav a:hover { background:var(--bg); text-decoration:none; }
+.pr-nav a.on { background:#eaf2ff; color:var(--accent); font-weight:600; border-right:2px solid var(--accent); }
+.pr-nav .sep { margin:10px 18px 6px; font-size:10px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
+.pr-main { flex:1; padding:22px 26px; min-width:0; }
+.pr-main h2 { margin:0 0 4px; font-size:19px; }
+.pr-main .lede { color:var(--muted); font-size:13px; margin:0 0 18px; }
+
+/* KPI cards */
+.kpis { display:grid; grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); gap:12px; margin:0 0 20px; }
+.kpi { background:#fff; border:1px solid var(--line); border-radius:12px; padding:14px 16px; }
+.kpi b { display:block; font-size:22px; } .kpi span { color:var(--muted); font-size:12px; }
+.kpi.ok b { color:var(--ok); } .kpi.warn b { color:var(--warn); } .kpi.bad b { color:var(--bad); }
+
+/* Panels */
+.panel { background:#fff; border:1px solid var(--line); border-radius:12px; padding:16px 18px; margin:0 0 16px; }
+.panel h3 { margin:0 0 10px; font-size:14px; }
+.cols2 { display:grid; grid-template-columns:1fr 1fr; gap:16px; } .cols3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px; }
+@media(max-width:1100px){ .cols2,.cols3 { grid-template-columns:1fr; } }
+
+/* Filter bar */
+.filters { display:flex; flex-wrap:wrap; gap:8px; margin:0 0 12px; align-items:center; }
+.filters input, .filters select { padding:7px 10px; border:1px solid var(--line); border-radius:8px; font-size:13px; background:#fff; }
+.filters input[type=search] { min-width:240px; }
+.btn { display:inline-block; padding:7px 14px; border-radius:8px; border:1px solid var(--line); background:#fff; font-size:13px; cursor:pointer; color:var(--ink); }
+.btn:hover { border-color:var(--accent); color:var(--accent); text-decoration:none; }
+.btn.primary { background:var(--accent); border-color:var(--accent); color:#fff; } .btn.primary:hover { opacity:.92; color:#fff; }
+.btn.danger { color:var(--bad); } .btn.danger:hover { border-color:var(--bad); }
+.btn.sm { padding:4px 9px; font-size:12px; border-radius:6px; }
+.btn[disabled] { opacity:.45; cursor:not-allowed; }
+
+/* Tables */
+table.grid { width:100%; border-collapse:collapse; background:#fff; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
+table.grid th, table.grid td { padding:9px 12px; text-align:left; border-bottom:1px solid var(--bg); font-size:13px; vertical-align:top; }
+table.grid th { background:#fbfcfe; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); cursor:pointer; white-space:nowrap; user-select:none; }
+table.grid tr:hover td { background:#f7faff; }
+table.grid tr.sel td { background:#eaf2ff; }
+.pager { display:flex; gap:8px; align-items:center; margin:12px 0; color:var(--muted); font-size:13px; }
+
+/* Badges */
+.b { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; font-weight:600; white-space:nowrap; }
+.b.gray { background:#eef1f5; color:var(--muted); } .b.blue { background:#eaf2ff; color:var(--accent); }
+.b.green { background:#e7f6ee; color:var(--ok); } .b.amber { background:#fdf3e2; color:var(--warn); }
+.b.red { background:#fdeeec; color:var(--bad); } .b.purple { background:#f1ebfb; color:var(--purple); }
+.score { display:inline-flex; align-items:center; gap:6px; }
+.score .bar { width:52px; height:6px; border-radius:3px; background:#eef1f5; overflow:hidden; }
+.score .bar i { display:block; height:100%; background:var(--accent); }
+.score.low .bar i { background:var(--bad); } .score.mid .bar i { background:var(--warn); } .score.high .bar i { background:var(--ok); }
+
+/* Drawer (detail) */
+.drawer { position:fixed; top:0; right:-720px; width:700px; max-width:92vw; height:100vh; background:#fff; border-left:1px solid var(--line); box-shadow:-16px 0 48px rgba(13,27,42,.12); transition:right .2s; z-index:60; display:flex; flex-direction:column; }
+.drawer.open { right:0; }
+.drawer .dhead { padding:16px 20px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:10px; }
+.drawer .dhead h3 { margin:0; font-size:16px; flex:1; }
+.drawer .dbody { padding:16px 20px; overflow:auto; flex:1; }
+.drawer .close { cursor:pointer; font-size:20px; color:var(--muted); background:none; border:none; }
+.kv { display:grid; grid-template-columns:150px 1fr; gap:4px 12px; font-size:13px; margin:0 0 14px; }
+.kv dt { color:var(--muted); } .kv dd { margin:0; word-break:break-word; }
+.evi { border-left:3px solid var(--line); padding:8px 12px; margin:0 0 10px; background:#fbfcfe; border-radius:0 8px 8px 0; font-size:12.5px; }
+.evi .f { font-weight:600; } .evi .x { color:var(--muted); font-style:italic; margin:4px 0; }
+.evi a { word-break:break-all; }
+
+/* Review 3-col */
+.review3 { display:grid; grid-template-columns:290px minmax(0,1fr) 360px; gap:14px; align-items:start; }
+@media(max-width:1200px){ .review3 { grid-template-columns:1fr; } }
+.rlist { background:#fff; border:1px solid var(--line); border-radius:12px; max-height:76vh; overflow:auto; }
+.rlist .it { padding:10px 14px; border-bottom:1px solid var(--bg); cursor:pointer; }
+.rlist .it:hover { background:#f7faff; } .rlist .it.on { background:#eaf2ff; border-right:3px solid var(--accent); }
+.rlist .it b { display:block; font-size:13px; } .rlist .it span { font-size:11.5px; color:var(--muted); }
+
+/* Letter builder */
+.blocks .blk { border:1px solid var(--line); border-radius:10px; margin:0 0 10px; background:#fff; }
+.blocks .blk .bh { display:flex; align-items:center; gap:8px; padding:8px 12px; border-bottom:1px solid var(--bg); font-size:12px; color:var(--muted); }
+.blocks .blk .bh b { color:var(--ink); }
+.blocks .blk textarea { width:100%; border:none; padding:10px 12px; font:13px/1.5 inherit; min-height:64px; resize:vertical; background:transparent; }
+.blocks .blk.locked { background:#fbfcfe; } .blocks .blk.locked textarea { color:var(--muted); }
+.letter-preview { border:1px solid var(--line); border-radius:10px; background:#fff; padding:22px 26px; font-size:14px; line-height:1.65; white-space:pre-wrap; }
+.mobile-frame { width:390px; margin:0 auto; border:10px solid #1a2733; border-radius:26px; overflow:hidden; }
+.missing { background:#fdf3e2; color:var(--warn); padding:1px 5px; border-radius:4px; font-weight:600; }
+
+/* Inbox */
+.thread .msg { border:1px solid var(--line); border-radius:10px; margin:0 0 10px; padding:12px 16px; background:#fff; }
+.thread .msg.in { border-left:3px solid var(--purple); } .thread .msg.out { border-left:3px solid var(--accent); }
+.thread .msg .mh { font-size:12px; color:var(--muted); margin:0 0 6px; display:flex; gap:10px; }
+.thread .msg pre { white-space:pre-wrap; font:13px/1.55 inherit; margin:0; }
+
+/* Forms */
+.form { display:grid; gap:10px; }
+.form label { font-size:12px; color:var(--muted); display:block; margin:0 0 3px; }
+.form input, .form select, .form textarea { width:100%; padding:8px 10px; border:1px solid var(--line); border-radius:8px; font:13px inherit; background:#fff; }
+.form textarea { min-height:80px; }
+.frow { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
+
+/* Toast */
+#toast { position:fixed; bottom:22px; left:50%; transform:translateX(-50%); background:var(--ink); color:#fff; padding:10px 18px; border-radius:10px; font-size:13px; opacity:0; pointer-events:none; transition:opacity .2s; z-index:99; max-width:70vw; }
+#toast.show { opacity:1; }
+#toast.err { background:var(--bad); }
+
+/* Loading / empty */
+.empty { color:var(--muted); text-align:center; padding:34px 0; font-size:13px; }
+.spin { display:inline-block; width:14px; height:14px; border:2px solid var(--line); border-top-color:var(--accent); border-radius:50%; animation:sp 1s linear infinite; vertical-align:-2px; }
+@keyframes sp { to { transform:rotate(360deg); } }
+.gatechk { display:flex; gap:8px; align-items:baseline; padding:6px 0; border-bottom:1px solid var(--bg); font-size:13px; }
+.gatechk .s { font-size:15px; }
+kbd { background:#eef1f5; border-radius:4px; padding:1px 6px; font-size:11px; border:1px solid var(--line); }
diff --git a/public/admin/pr-intelligence/pr-shared.js b/public/admin/pr-intelligence/pr-shared.js
new file mode 100644
index 00000000..adae3e40
--- /dev/null
+++ b/public/admin/pr-intelligence/pr-shared.js
@@ -0,0 +1,220 @@
+'use strict';
+// Shared workspace runtime: frame (top bar + nav), API helper, table renderer,
+// URL-persisted filters, drawer, toast, global search, keyboard nav helpers.
+(function () {
+  const NAV = [
+    ['', '📊', 'Dashboard'],
+    ['research', '🔎', 'Research'],
+    ['organizations', '🏢', 'Organizations'],
+    ['people', '👤', 'People'],
+    ['review', '✅', 'Review Queue'],
+    ['__sep1', '', 'Outreach'],
+    ['campaigns', '📮', 'Campaigns'],
+    ['letters', '✍️', 'Letter Builder'],
+    ['inbox', '📥', 'Inbox'],
+    ['tasks', '🗓️', 'Tasks'],
+    ['__sep2', '', 'System'],
+    ['sources', '📚', 'Sources'],
+    ['runs', '⚙️', 'Runs & Jobs'],
+    ['settings', '🛠️', 'Settings'],
+  ];
+
+  window.PR = {
+    // ── API ──────────────────────────────────────────────────────────────────
+    async api(path, opts = {}) {
+      const r = await fetch('/api/pr' + path, {
+        headers: { 'Content-Type': 'application/json' },
+        ...opts,
+        body: opts.body != null && typeof opts.body !== 'string' ? JSON.stringify(opts.body) : opts.body,
+      });
+      let j = null;
+      try { j = await r.json(); } catch { j = { ok: false, error: 'bad response (' + r.status + ')' }; }
+      if (!r.ok) { const e = new Error((j && j.error) || ('HTTP ' + r.status)); e.status = r.status; e.body = j; throw e; }
+      return j;
+    },
+
+    // ── Frame ────────────────────────────────────────────────────────────────
+    frame(active, title, lede) {
+      document.body.insertAdjacentHTML('afterbegin', `
+        <div class="pr-top">
+          <h1><a href="/admin/pr-intelligence">CRE PR Intelligence</a></h1><span class="tag">Admin</span>
+          <a href="/backend" style="font-size:12.5px">← Backend</a>
+          <div class="gsearch"><input id="gsearch" type="search" placeholder="Search orgs, people, emails, LinkedIn, metros… ( / )">
+            <div class="results" id="gsearch-results"></div></div>
+        </div>
+        <div class="pr-wrap">
+          <nav class="pr-nav">${NAV.map(([slug, ico, label]) =>
+            slug.startsWith('__sep') ? `<div class="sep">${label}</div>` :
+            `<a href="/admin/pr-intelligence${slug ? '/' + slug : ''}" class="${slug === active ? 'on' : ''}">${ico} ${label}</a>`).join('')}
+          </nav>
+          <main class="pr-main">
+            <h2>${title}</h2><p class="lede">${lede || ''}</p>
+            <div id="pr-content"></div>
+          </main>
+        </div>
+        <div id="toast"></div>`);
+      // global search
+      const inp = document.getElementById('gsearch');
+      const box = document.getElementById('gsearch-results');
+      let t = null;
+      inp.addEventListener('input', () => {
+        clearTimeout(t);
+        t = setTimeout(async () => {
+          const q = inp.value.trim();
+          if (q.length < 2) { box.classList.remove('open'); return; }
+          try {
+            const j = await PR.api('/search?q=' + encodeURIComponent(q));
+            box.innerHTML = j.groups.length ? j.groups.map((g) =>
+              `<div class="g">${g.type}</div>` + g.items.map((x) =>
+                `<a href="${x.href}"><b>${PR.esc(x.title)}</b><br><span style="color:var(--muted);font-size:12px">${PR.esc(x.sub || '')}</span></a>`).join('')
+            ).join('') : '<div class="empty">No matches</div>';
+            box.classList.add('open');
+          } catch { box.classList.remove('open'); }
+        }, 220);
+      });
+      document.addEventListener('click', (e) => { if (!e.target.closest('.gsearch')) box.classList.remove('open'); });
+      document.addEventListener('keydown', (e) => {
+        if (e.key === '/' && !/input|textarea|select/i.test(document.activeElement.tagName)) { e.preventDefault(); inp.focus(); }
+      });
+      return document.getElementById('pr-content');
+    },
+
+    // ── Utilities ────────────────────────────────────────────────────────────
+    esc(s) { return String(s == null ? '' : s).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c])); },
+    date(s) { return s ? new Date(s).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—'; },
+    dateShort(s) { return s ? new Date(s).toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' }) : '—'; },
+    toast(msg, err) {
+      const t = document.getElementById('toast');
+      t.textContent = msg; t.className = 'show' + (err ? ' err' : '');
+      setTimeout(() => { t.className = ''; }, err ? 5000 : 2600);
+    },
+    async guard(fn, okMsg) {
+      try { const r = await fn(); if (okMsg) PR.toast(okMsg); return r; }
+      catch (e) { PR.toast(e.message, true); throw e; }
+    },
+    score(n) {
+      const cls = n >= 70 ? 'high' : n >= 40 ? 'mid' : 'low';
+      return `<span class="score ${cls}"><span class="bar"><i style="width:${n}%"></i></span>${n}</span>`;
+    },
+    badge(text, color) { return `<span class="b ${color || 'gray'}">${PR.esc(text)}</span>`; },
+    statusBadge(s) {
+      const map = { verified: 'green', ready_for_outreach: 'green', active_relationship: 'green', contacted: 'blue', replied: 'blue',
+        discovered: 'gray', needs_review: 'amber', needs_verification: 'amber', incomplete: 'amber', stale: 'amber', follow_up_due: 'amber',
+        duplicate: 'purple', archived: 'gray', suppressed: 'red', wrong_person: 'red', left_company: 'red', bounced: 'red', opted_out: 'red', declined: 'red',
+        draft_generated: 'gray', approved: 'blue', provider_draft_created: 'blue', sent: 'blue', interested: 'green', referred: 'purple', not_now: 'amber', closed: 'gray',
+        queued: 'gray', running: 'blue', paused: 'amber', completed: 'green', failed: 'red', cancelled: 'gray',
+      };
+      return PR.badge((s || '').replace(/_/g, ' '), map[s] || 'gray');
+    },
+    verifBadge(s) {
+      const map = { manually_verified: 'green', company_site_verified: 'green', press_release_verified: 'green', registry_verified: 'green',
+        corroborated: 'blue', search_indexed: 'amber', inferred: 'red', unverified: 'gray', stale: 'amber', invalid: 'red',
+        found_corroborated: 'blue', found_uncorroborated: 'amber', none: 'gray' };
+      return PR.badge((s || 'unverified').replace(/_/g, ' '), map[s] || 'gray');
+    },
+
+    // URL-persisted filters: read current query, write on change, call onChange.
+    filters(defs, onChange) {
+      const p = new URLSearchParams(location.search);
+      const state = {};
+      defs.forEach((d) => { state[d.key] = p.get(d.key) || d.value || ''; });
+      const bar = document.createElement('div'); bar.className = 'filters';
+      defs.forEach((d) => {
+        let el;
+        if (d.type === 'select') {
+          el = document.createElement('select');
+          (d.options || []).forEach(([v, label]) => {
+            const o = document.createElement('option'); o.value = v; o.textContent = label; el.appendChild(o);
+          });
+          el.value = state[d.key];
+        } else {
+          el = document.createElement('input');
+          el.type = d.type || 'search'; el.placeholder = d.placeholder || d.key;
+          el.value = state[d.key];
+        }
+        el.dataset.key = d.key;
+        let deb = null;
+        el.addEventListener(d.type === 'select' ? 'change' : 'input', () => {
+          clearTimeout(deb);
+          deb = setTimeout(() => {
+            state[d.key] = el.value;
+            const q = new URLSearchParams(location.search);
+            if (el.value) q.set(d.key, el.value); else q.delete(d.key);
+            history.replaceState(null, '', location.pathname + (q.toString() ? '?' + q : ''));
+            onChange(state);
+          }, d.type === 'select' ? 0 : 300);
+        });
+        bar.appendChild(el);
+      });
+      return { bar, state };
+    },
+
+    // Sortable table renderer. cols: [{key,label,render(row)}] — click header toggles sort.
+    table(cols, { onSort, onRow } = {}) {
+      const el = document.createElement('table'); el.className = 'grid';
+      el.innerHTML = `<thead><tr>${cols.map((c) => `<th data-k="${c.key || ''}">${c.label}</th>`).join('')}</tr></thead><tbody></tbody>`;
+      let sort = null, dir = 'desc';
+      el.querySelectorAll('th[data-k]').forEach((th) => {
+        th.addEventListener('click', () => {
+          if (!th.dataset.k || !onSort) return;
+          dir = sort === th.dataset.k && dir === 'desc' ? 'asc' : 'desc';
+          sort = th.dataset.k;
+          onSort(sort, dir);
+        });
+      });
+      return {
+        el,
+        render(rows) {
+          const tb = el.querySelector('tbody');
+          tb.innerHTML = rows.length ? rows.map((r, i) =>
+            `<tr data-i="${i}">${cols.map((c) => `<td>${c.render ? c.render(r) : PR.esc(r[c.key])}</td>`).join('')}</tr>`).join('')
+            : `<tr><td colspan="${cols.length}"><div class="empty">Nothing here yet</div></td></tr>`;
+          if (onRow) tb.querySelectorAll('tr[data-i]').forEach((tr) =>
+            tr.addEventListener('click', (e) => { if (!e.target.closest('a,button,input,select')) onRow(rows[Number(tr.dataset.i)], tr); }));
+        },
+      };
+    },
+
+    drawer() {
+      let d = document.querySelector('.drawer');
+      if (!d) {
+        d = document.createElement('div'); d.className = 'drawer';
+        d.innerHTML = `<div class="dhead"><h3 id="d-title"></h3><button class="close" title="Close (Esc)">×</button></div><div class="dbody" id="d-body"></div>`;
+        document.body.appendChild(d);
+        d.querySelector('.close').addEventListener('click', () => d.classList.remove('open'));
+        document.addEventListener('keydown', (e) => { if (e.key === 'Escape') d.classList.remove('open'); });
+      }
+      return {
+        open(title, html) { d.querySelector('#d-title').innerHTML = title; d.querySelector('#d-body').innerHTML = html; d.classList.add('open'); return d.querySelector('#d-body'); },
+        close() { d.classList.remove('open'); },
+        el: d,
+      };
+    },
+
+    pager(total, limit, offset, onPage) {
+      const el = document.createElement('div'); el.className = 'pager';
+      const pages = Math.max(1, Math.ceil(total / limit));
+      const cur = Math.floor(offset / limit) + 1;
+      el.innerHTML = `<button class="btn sm" ${cur <= 1 ? 'disabled' : ''} data-p="prev">‹ Prev</button>
+        <span>${total.toLocaleString()} rows · page ${cur} / ${pages}</span>
+        <button class="btn sm" ${cur >= pages ? 'disabled' : ''} data-p="next">Next ›</button>`;
+      el.querySelectorAll('button').forEach((b) => b.addEventListener('click', () =>
+        onPage(b.dataset.p === 'next' ? offset + limit : Math.max(0, offset - limit))));
+      return el;
+    },
+
+    // Evidence list HTML (used by org/person drawers + review center pane)
+    evidenceHtml(evidence, opts = {}) {
+      if (!evidence || !evidence.length) return '<div class="empty">No source evidence recorded</div>';
+      return evidence.map((e) => `
+        <div class="evi">
+          <span class="f">${PR.esc(e.field_name)}</span> ${e.field_value ? '= ' + PR.esc(String(e.field_value).slice(0, 120)) : ''}
+          · confidence ${e.confidence} · ${PR.badge(e.source_type, e.is_primary_source ? 'green' : 'gray')}
+          ${e.short_excerpt ? `<div class="x">“${PR.esc(e.short_excerpt.slice(0, 220))}”</div>` : ''}
+          <div>${e.url ? `<a href="${PR.esc(e.url)}" target="_blank" rel="noopener noreferrer">${PR.esc(e.url.slice(0, 90))}</a> · ` : ''}
+          retrieved ${PR.dateShort(e.retrieved_at)}${e.license_or_usage_note ? ` · <span title="${PR.esc(e.license_or_usage_note)}">terms ⓘ</span>` : ''}
+          ${opts.addToLetter ? ` · <a href="#" data-addletter="${e.source_id}" data-excerpt="${PR.esc(e.short_excerpt || e.field_value || '')}">+ Add to letter</a>` : ''}</div>
+        </div>`).join('');
+    },
+  };
+})();
diff --git a/server.js b/server.js
index 83ca24a6..e7acc7c7 100644
--- a/server.js
+++ b/server.js
@@ -708,6 +708,11 @@ app.delete('/api/social/draft/:id', adminOnly, (req, res) => {
   res.json({ ok: true, removed: drafts.length - n.length });
 });
 
+// ── CRE PR Intelligence workspace (/admin/pr-intelligence + /api/pr/*) ──────
+// Self-contained module (own Postgres db `rentv_pr`); admin-only end to end. If its
+// DB is down, its APIs 503 cleanly and the rest of the site is unaffected.
+require('./src/pr')(app, { adminOnly, sendPage, PUB });
+
 // ── Defense-in-depth static guard ───────────────────────────────────────────
 // The data APIs above are already admin-only; this additionally blocks user-tier
 // logins from loading any INTERNAL static shell/asset directly (desk, audience,
diff --git a/src/pr/index.js b/src/pr/index.js
new file mode 100644
index 00000000..4752e677
--- /dev/null
+++ b/src/pr/index.js
@@ -0,0 +1,426 @@
+'use strict';
+// CRE PR Intelligence — mount point. server.js calls:
+//   require('./src/pr')(app, { adminOnly, sendPage, PUB });
+// Everything registers under /api/pr/* (admin-only JSON) and /admin/pr-intelligence/*
+// (admin-only shells). If Postgres is unavailable the APIs return a clean 503 and the
+// rest of the rentv site is untouched.
+const path = require('path');
+const db = require('./db');
+const settings = require('./services/settings');
+const organizations = require('./services/organizations');
+const people = require('./services/people');
+const sources = require('./services/sources');
+const relationships = require('./services/relationships');
+const campaigns = require('./services/campaigns');
+const letters = require('./services/letters');
+const outreach = require('./services/outreach');
+const replies = require('./services/replies');
+const tasksSvc = require('./services/tasks');
+const suppression = require('./services/suppression');
+const auditSvc = require('./services/audit');
+const runs = require('./services/runs');
+const importexport = require('./services/importexport');
+const providers = require('./services/email-providers');
+const jobs = require('./jobs');
+const adapters = require('./adapters');
+const geo = require('./lib/geo');
+const { ORG_TYPES, ASSET_CLASSES } = require('./lib/taxonomy');
+
+const PAGES = ['index', 'research', 'organizations', 'people', 'review', 'campaigns', 'letters', 'inbox', 'tasks', 'sources', 'runs', 'settings'];
+
+module.exports = function mountPR(app, { adminOnly, sendPage }) {
+  const SHELL_DIR = path.join(__dirname, '..', '..', 'public', 'admin', 'pr-intelligence');
+
+  // Actor = the basic-auth username of the admin performing the action (audit trail).
+  function actorOf(req) {
+    try {
+      const h = req.headers.authorization || '';
+      if (!h.startsWith('Basic ')) return 'admin';
+      return Buffer.from(h.slice(6), 'base64').toString('utf8').split(':')[0] || 'admin';
+    } catch { return 'admin'; }
+  }
+
+  // Async wrapper: JSON errors, 503 when the DB is down, one-time lazy migration.
+  let migrated = false;
+  function h(fn) {
+    return async (req, res) => {
+      try {
+        if (!migrated) {
+          const ok = await db.health();
+          if (!ok.ok) return res.status(503).json({ ok: false, db: 'unavailable', reason: ok.reason, hint: ok.hint });
+          if (!ok.migrations) await db.runMigrations({});
+          migrated = true;
+        }
+        await fn(req, res);
+      } catch (e) {
+        const code = /not found/i.test(e.message) ? 404
+          : /required|invalid|must|cannot|bad |unknown|requires|blocked|locked|mismatch|only /i.test(e.message) ? 400 : 500;
+        if (e.code === 'AZ_LOCKED') return res.status(423).json({ ok: false, error: e.message, code: 'AZ_LOCKED' });
+        res.status(code).json({ ok: false, error: e.message });
+      }
+    };
+  }
+
+  // ── Health & meta ──────────────────────────────────────────────────────────
+  app.get('/api/pr/health', adminOnly, async (_q, res) => res.json(await db.health()));
+  app.get('/api/pr/meta', adminOnly, h(async (_q, res) => {
+    res.json({
+      states: Object.entries(geo.STATES).map(([k, v]) => ({ key: k, label: v.label, metros: v.metros })),
+      org_types: ORG_TYPES.map(([key, label]) => ({ key, label })),
+      asset_classes: ASSET_CLASSES,
+      job_types: jobs.JOB_TYPES,
+      adapters: adapters.listAdapters(),
+      email_provider: providers.providerStatus(),
+    });
+  }));
+
+  // ── Dashboard ──────────────────────────────────────────────────────────────
+  app.get('/api/pr/dashboard', adminOnly, h(async (_q, res) => {
+    const orgTotals = await db.one(
+      `SELECT count(*)::int AS total,
+              count(*) FILTER (WHERE 'CA'=ANY(state_presence))::int AS ca,
+              count(*) FILTER (WHERE 'AZ'=ANY(state_presence))::int AS az,
+              count(*) FILTER (WHERE lifecycle_status IN ('verified','ready_for_outreach','contacted','active_relationship'))::int AS verified,
+              count(*) FILTER (WHERE lifecycle_status IN ('discovered','needs_review'))::int AS unreviewed,
+              count(*) FILTER (WHERE lifecycle_status='duplicate')::int AS duplicates,
+              count(*) FILTER (WHERE created_at > now() - interval '7 days')::int AS new7
+         FROM pr_organizations WHERE lifecycle_status <> 'archived'`, []);
+    const pplTotals = await db.one(
+      `SELECT count(*)::int AS total,
+              count(*) FILTER (WHERE lifecycle_status IN ('verified','ready_for_outreach','contacted','replied'))::int AS verified,
+              count(*) FILTER (WHERE lifecycle_status IN ('discovered','needs_verification'))::int AS unverified,
+              count(*) FILTER (WHERE lifecycle_status='stale')::int AS stale,
+              count(*) FILTER (WHERE lifecycle_status='duplicate')::int AS duplicates,
+              count(*) FILTER (WHERE created_at > now() - interval '7 days')::int AS new7
+         FROM pr_people`, []);
+    const byCategory = await db.rows(
+      `SELECT organization_type, count(*)::int AS n FROM pr_organizations
+        WHERE lifecycle_status NOT IN ('duplicate','archived') GROUP BY 1 ORDER BY n DESC`, []);
+    const byRole = await db.rows(
+      `SELECT coalesce(department,'other') AS department, count(*)::int AS n FROM pr_people
+        WHERE lifecycle_status NOT IN ('duplicate','suppressed') GROUP BY 1 ORDER BY n DESC`, []);
+    const byMetro = await db.rows(
+      `SELECT m AS metro, count(*)::int AS n FROM pr_organizations, unnest(metros) m
+        WHERE lifecycle_status NOT IN ('duplicate','archived') GROUP BY 1 ORDER BY n DESC`, []);
+    const noPr = await db.one(
+      `SELECT count(*)::int AS n FROM pr_organizations o
+        WHERE lifecycle_status NOT IN ('duplicate','archived')
+          AND NOT EXISTS (SELECT 1 FROM pr_people p WHERE p.organization_id=o.id
+             AND p.department IN ('communications','agency') AND p.lifecycle_status NOT IN ('duplicate','suppressed'))`, []);
+    const agencies = await db.rows(
+      `SELECT o.id, o.display_name,
+              (SELECT count(*) FROM pr_org_relationships r WHERE r.source_organization_id=o.id AND r.relationship_type='agency_client')::int AS clients
+         FROM pr_organizations o WHERE o.organization_type IN ('pr_agency','marketing_agency','public_affairs')
+          AND o.lifecycle_status NOT IN ('duplicate','archived') ORDER BY clients DESC LIMIT 15`, []);
+    const drafts = await db.one(`SELECT count(*)::int AS n FROM pr_outreach_messages WHERE status IN ('draft_generated','needs_review')`, []);
+    const inboxCounts = await db.one(
+      `SELECT count(*) FILTER (WHERE direction='inbound' AND status='replied')::int AS replies,
+              count(*) FILTER (WHERE direction='outbound' AND status='follow_up_due')::int AS followups
+         FROM pr_outreach_messages`, []);
+    const runHealth = await runs.health();
+    const gate = await settings.get('california_gate', { passed: false });
+    const azUnlocked = await settings.arizonaUnlocked();
+    const coverage = await settings.get('coverage_report_CA', null);
+    // CA completion: verified CA orgs vs all CA orgs + metro×category cells with any coverage
+    const caCells = await db.one(
+      `SELECT count(DISTINCT (m, organization_type))::int AS covered
+         FROM pr_organizations, unnest(metros) m
+        WHERE 'CA'=ANY(state_presence) AND lifecycle_status NOT IN ('duplicate','archived')`, []);
+    const totalCells = geo.CA_METROS.length * ORG_TYPES.length;
+    res.json({
+      california: {
+        organizations: orgTotals.ca,
+        completion_pct: Math.min(100, Math.round((caCells.covered / totalCells) * 100)),
+        cells_covered: caCells.covered, cells_total: totalCells,
+        gate,
+      },
+      arizona: { unlocked: azUnlocked, organizations: orgTotals.az, status: azUnlocked ? 'active' : 'locked' },
+      organizations: orgTotals, people: pplTotals,
+      by_category: byCategory, by_role: byRole, by_metro: byMetro,
+      orgs_without_pr_contact: noPr.n,
+      pr_agencies: agencies,
+      drafts_awaiting_review: drafts.n,
+      replies_requiring_action: inboxCounts.replies,
+      followups_due: inboxCounts.followups,
+      run_health: runHealth,
+      adapters: adapters.listAdapters(),
+      coverage_gaps: coverage ? { generated_at: coverage.generated_at, gap_count: coverage.gap_count, sample: (coverage.gaps || []).slice(0, 40) } : null,
+    });
+  }));
+
+  // ── Global search across the intelligence workspace ────────────────────────
+  app.get('/api/pr/search', adminOnly, h(async (req, res) => {
+    const q = String(req.query.q || '').trim();
+    if (q.length < 2) return res.json({ q, groups: [] });
+    const like = '%' + q.toLowerCase() + '%';
+    const orgs = await db.rows(
+      `SELECT id, display_name AS title, organization_type AS sub FROM pr_organizations
+        WHERE lifecycle_status <> 'duplicate' AND (lower(display_name) LIKE $1 OR lower(coalesce(domain,'')) LIKE $1
+           OR lower(coalesce(notes,'')) LIKE $1 OR EXISTS (SELECT 1 FROM unnest(metros) m WHERE lower(m) LIKE $1)
+           OR EXISTS (SELECT 1 FROM unnest(counties) c WHERE lower(c) LIKE $1)) LIMIT 10`, [like]);
+    const ppl = await db.rows(
+      `SELECT p.id, p.full_name AS title, coalesce(p.exact_title,'') || ' · ' || coalesce(o.display_name,'') AS sub
+         FROM pr_people p LEFT JOIN pr_organizations o ON o.id=p.organization_id
+        WHERE p.lifecycle_status <> 'duplicate' AND (lower(p.full_name) LIKE $1 OR lower(coalesce(p.exact_title,'')) LIKE $1
+           OR lower(coalesce(p.public_work_email,'')) LIKE $1 OR lower(coalesce(p.linkedin_url,'')) LIKE $1) LIMIT 10`, [like]);
+    const threads = await db.rows(
+      `SELECT id, coalesce(subject,'(no subject)') AS title, status AS sub, provider_thread_id
+         FROM pr_outreach_messages WHERE lower(coalesce(subject,'')) LIKE $1 OR lower(coalesce(rendered_text,'')) LIKE $1 LIMIT 8`, [like]);
+    res.json({
+      q,
+      groups: [
+        { type: 'Organizations', items: orgs.map((x) => ({ ...x, href: '/admin/pr-intelligence/organizations?id=' + x.id })) },
+        { type: 'People', items: ppl.map((x) => ({ ...x, href: '/admin/pr-intelligence/people?id=' + x.id })) },
+        { type: 'Threads', items: threads.map((x) => ({ ...x, href: '/admin/pr-intelligence/inbox?thread=' + (x.provider_thread_id || x.id) })) },
+      ].filter((g) => g.items.length),
+    });
+  }));
+
+  // ── Organizations ──────────────────────────────────────────────────────────
+  app.get('/api/pr/organizations', adminOnly, h(async (req, res) => res.json(await organizations.list(req.query))));
+  app.get('/api/pr/organizations/:id', adminOnly, h(async (req, res) => {
+    const org = await organizations.get(Number(req.params.id));
+    if (!org) return res.status(404).json({ error: 'not found' });
+    res.json(org);
+  }));
+  app.post('/api/pr/organizations', adminOnly, h(async (req, res) => {
+    const { evidence, ...data } = req.body || {};
+    res.json(await organizations.create(data, { evidence, actor: actorOf(req) }));
+  }));
+  app.put('/api/pr/organizations/:id', adminOnly, h(async (req, res) =>
+    res.json(await organizations.update(Number(req.params.id), req.body || {}, actorOf(req)))));
+  app.post('/api/pr/organizations/:id/merge', adminOnly, h(async (req, res) =>
+    res.json(await organizations.merge(Number(req.params.id), Number((req.body || {}).duplicate_id), actorOf(req)))));
+  app.post('/api/pr/organizations/:id/evidence', adminOnly, h(async (req, res) =>
+    res.json(await organizations.attachEvidence(Number(req.params.id), req.body || {}, actorOf(req)))));
+  app.get('/api/pr/organizations/:id/matches', adminOnly, h(async (req, res) => {
+    const org = await db.one('SELECT * FROM pr_organizations WHERE id=$1', [Number(req.params.id)]);
+    if (!org) return res.status(404).json({ error: 'not found' });
+    const m = (await organizations.findMatches(org)).filter((x) => x.org.id !== org.id);
+    res.json({ matches: m });
+  }));
+
+  // ── People ─────────────────────────────────────────────────────────────────
+  app.get('/api/pr/people', adminOnly, h(async (req, res) => res.json(await people.list(req.query))));
+  app.get('/api/pr/people/:id', adminOnly, h(async (req, res) => {
+    const p = await people.get(Number(req.params.id));
+    if (!p) return res.status(404).json({ error: 'not found' });
+    res.json(p);
+  }));
+  app.post('/api/pr/people', adminOnly, h(async (req, res) => {
+    const { evidence, ...data } = req.body || {};
+    res.json(await people.create(data, { evidence, actor: actorOf(req) }));
+  }));
+  app.put('/api/pr/people/:id', adminOnly, h(async (req, res) =>
+    res.json(await people.update(Number(req.params.id), req.body || {}, actorOf(req)))));
+  app.post('/api/pr/people/:id/merge', adminOnly, h(async (req, res) =>
+    res.json(await people.merge(Number(req.params.id), Number((req.body || {}).duplicate_id), actorOf(req)))));
+  app.post('/api/pr/people/:id/evidence', adminOnly, h(async (req, res) =>
+    res.json(await people.attachEvidence(Number(req.params.id), req.body || {}, actorOf(req)))));
+
+  // ── Review queue ───────────────────────────────────────────────────────────
+  app.get('/api/pr/review/queue', adminOnly, h(async (req, res) => {
+    const kind = req.query.kind === 'people' ? 'people' : 'organizations';
+    if (kind === 'organizations') {
+      const r = await organizations.list({ ...req.query, status: req.query.status || 'needs_review', sort: 'confidence', dir: 'asc', limit: req.query.limit || 100 });
+      return res.json({ kind, ...r });
+    }
+    const r = await people.list({ ...req.query, status: req.query.status || 'needs_verification', sort: 'confidence', dir: 'asc', limit: req.query.limit || 100 });
+    res.json({ kind, ...r });
+  }));
+  app.post('/api/pr/review/:kind/:id', adminOnly, h(async (req, res) => {
+    const actor = actorOf(req);
+    const { action, patch } = req.body || {};
+    const id = Number(req.params.id);
+    const isOrg = req.params.kind === 'organization';
+    const map = {
+      accept: isOrg ? { lifecycle_status: 'verified', verification_status: 'manually_verified', last_verified_at: new Date().toISOString() }
+                    : { lifecycle_status: 'verified', verification_status: 'manually_verified', last_verified_at: new Date().toISOString() },
+      reject: isOrg ? { lifecycle_status: 'archived' } : { lifecycle_status: 'wrong_person' },
+      stale: isOrg ? { lifecycle_status: 'incomplete' } : { lifecycle_status: 'stale' },
+      defer: isOrg ? { lifecycle_status: 'needs_review' } : { lifecycle_status: 'needs_verification' },
+    };
+    if (action === 'edit') {
+      const row = isOrg ? await organizations.update(id, patch || {}, actor) : await people.update(id, patch || {}, actor);
+      await auditSvc.log({ actor, action: 'review.edit', entity_type: isOrg ? 'organization' : 'person', entity_id: id });
+      return res.json({ ok: true, row });
+    }
+    if (!map[action]) return res.status(400).json({ ok: false, error: 'unknown review action' });
+    const row = isOrg ? await organizations.update(id, map[action], actor) : await people.update(id, map[action], actor);
+    await auditSvc.log({ actor, action: 'review.' + action, entity_type: isOrg ? 'organization' : 'person', entity_id: id });
+    res.json({ ok: true, row });
+  }));
+
+  // ── Relationships ──────────────────────────────────────────────────────────
+  app.get('/api/pr/relationships', adminOnly, h(async (req, res) => res.json(await relationships.list(req.query))));
+  app.post('/api/pr/relationships', adminOnly, h(async (req, res) => res.json(await relationships.create({ ...(req.body || {}), actor: actorOf(req) }))));
+  app.delete('/api/pr/relationships/:id', adminOnly, h(async (req, res) => res.json(await relationships.remove(Number(req.params.id), actorOf(req)))));
+
+  // ── Sources & evidence ─────────────────────────────────────────────────────
+  app.get('/api/pr/sources', adminOnly, h(async (req, res) => res.json(await sources.listSources(req.query))));
+  app.get('/api/pr/evidence/:entityType/:id', adminOnly, h(async (req, res) =>
+    res.json({ evidence: await sources.evidenceFor(req.params.entityType, Number(req.params.id)) })));
+
+  // ── Research runs & jobs ───────────────────────────────────────────────────
+  app.get('/api/pr/runs', adminOnly, h(async (req, res) => res.json({ rows: await runs.list(req.query), health: await runs.health() })));
+  app.post('/api/pr/runs/:id/pause', adminOnly, h(async (req, res) => res.json(await runs.pause(Number(req.params.id), actorOf(req)))));
+  app.post('/api/pr/runs/:id/resume', adminOnly, h(async (req, res) => res.json(await runs.resume(Number(req.params.id), actorOf(req)))));
+  app.get('/api/pr/jobs', adminOnly, h(async (req, res) => res.json({ rows: await jobs.listJobs(req.query) })));
+  app.post('/api/pr/jobs', adminOnly, h(async (req, res) => {
+    const { job_type, payload, priority } = req.body || {};
+    if (!jobs.JOB_TYPES.includes(job_type)) return res.status(400).json({ ok: false, error: 'unknown job_type', known: jobs.JOB_TYPES });
+    const row = await jobs.enqueue(job_type, payload || {}, { priority: priority || 5, actor: actorOf(req) });
+    res.json({ ok: true, job: row });
+  }));
+  app.post('/api/pr/jobs/:id/status', adminOnly, h(async (req, res) =>
+    res.json({ ok: true, job: await jobs.setJobStatus(Number(req.params.id), (req.body || {}).status, actorOf(req)) })));
+  // convenience: start a California research wave for a metro/category
+  app.post('/api/pr/research/start', adminOnly, h(async (req, res) => {
+    const { state = 'CA', metro, category } = req.body || {};
+    const job = await jobs.enqueue('discover-organizations', { state, metro: metro || null, category: category || null }, { priority: 4, actor: actorOf(req) });
+    res.json({ ok: true, job });
+  }));
+
+  // ── Query matrix ───────────────────────────────────────────────────────────
+  app.get('/api/pr/query-templates', adminOnly, h(async (_q, res) =>
+    res.json({ rows: await db.rows('SELECT * FROM pr_query_templates ORDER BY id', []) })));
+  app.post('/api/pr/query-templates', adminOnly, h(async (req, res) => {
+    const b = req.body || {};
+    if (b.id) {
+      const row = (await db.query(
+        `UPDATE pr_query_templates SET name=$2, template=$3, target=$4, org_types=$5, states=$6, enabled=$7, notes=$8 WHERE id=$1 RETURNING *`,
+        [b.id, b.name, b.template, b.target || 'organization', b.org_types || [], b.states || [], b.enabled !== false, b.notes || null])).rows[0];
+      return res.json({ ok: true, row });
+    }
+    const row = (await db.query(
+      `INSERT INTO pr_query_templates (name, template, target, org_types, states, enabled, notes)
+       VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
+      [b.name, b.template, b.target || 'organization', b.org_types || [], b.states || [], b.enabled !== false, b.notes || null])).rows[0];
+    res.json({ ok: true, row });
+  }));
+
+  // ── Campaigns ──────────────────────────────────────────────────────────────
+  app.get('/api/pr/campaigns', adminOnly, h(async (_q, res) => res.json({ rows: await campaigns.list() })));
+  app.get('/api/pr/campaigns/:id', adminOnly, h(async (req, res) => {
+    const c = await campaigns.get(Number(req.params.id));
+    if (!c) return res.status(404).json({ error: 'not found' });
+    res.json(c);
+  }));
+  app.post('/api/pr/campaigns', adminOnly, h(async (req, res) => res.json(await campaigns.create(req.body || {}, actorOf(req)))));
+  app.put('/api/pr/campaigns/:id', adminOnly, h(async (req, res) => res.json(await campaigns.update(Number(req.params.id), req.body || {}, actorOf(req)))));
+  app.post('/api/pr/campaigns/:id/enroll', adminOnly, h(async (req, res) =>
+    res.json(await campaigns.enroll(Number(req.params.id), (req.body || {}).person_ids || [], actorOf(req)))));
+  app.delete('/api/pr/campaigns/:id/members/:personId', adminOnly, h(async (req, res) =>
+    res.json(await campaigns.removeMember(Number(req.params.id), Number(req.params.personId), actorOf(req)))));
+
+  // ── Letters ────────────────────────────────────────────────────────────────
+  app.get('/api/pr/letters/templates', adminOnly, h(async (_q, res) => res.json({ rows: await letters.listTemplates() })));
+  app.get('/api/pr/letters/templates/:id/versions', adminOnly, h(async (req, res) => res.json({ rows: await letters.versions(Number(req.params.id)) })));
+  app.get('/api/pr/letters/versions/:id', adminOnly, h(async (req, res) => {
+    const v = await letters.getVersion(Number(req.params.id));
+    if (!v) return res.status(404).json({ error: 'not found' });
+    res.json(v);
+  }));
+  app.post('/api/pr/letters/templates/:id/versions', adminOnly, h(async (req, res) =>
+    res.json(await letters.saveVersion(Number(req.params.id), { ...(req.body || {}), actor: actorOf(req) }))));
+  app.get('/api/pr/letters/compare', adminOnly, h(async (req, res) => {
+    const a = await letters.getVersion(Number(req.query.a));
+    const b = await letters.getVersion(Number(req.query.b));
+    if (!a || !b) return res.status(404).json({ error: 'version not found' });
+    res.json(letters.compareVersions(a, b));
+  }));
+  app.get('/api/pr/letters/blocks', adminOnly, h(async (req, res) => res.json({ rows: await letters.listBlocks(req.query) })));
+  app.post('/api/pr/letters/blocks', adminOnly, h(async (req, res) => res.json(await letters.saveBlock(req.body || {}, actorOf(req)))));
+  app.post('/api/pr/letters/preview', adminOnly, h(async (req, res) => {
+    const { person_id, organization_id, blocks, subject_template, extra_block_ids } = req.body || {};
+    const person = person_id ? await db.one('SELECT * FROM pr_people WHERE id=$1', [person_id]) : null;
+    const org = organization_id ? await db.one('SELECT * FROM pr_organizations WHERE id=$1', [organization_id])
+      : person && person.organization_id ? await db.one('SELECT * FROM pr_organizations WHERE id=$1', [person.organization_id]) : null;
+    const tpl = await letters.baseTemplate();
+    const useBlocks = blocks || (tpl && tpl.current && tpl.current.blocks) || [];
+    const subj = subject_template || (tpl && tpl.current && tpl.current.subject_template) || '';
+    const context = await letters.mergeContext({ person, org });
+    const extra = (extra_block_ids || []).length ? await db.rows('SELECT * FROM pr_letter_blocks WHERE id = ANY($1::bigint[])', [extra_block_ids]) : [];
+    const vertical = org ? await letters.listBlocks({ scope: 'vertical', org_type: org.organization_type }) : [];
+    const orgSpecific = org ? await letters.listBlocks({ scope: 'organization', organization_id: org.id }) : [];
+    const orgBlock = [...orgSpecific.filter((b) => b.organization_id === (org && org.id)), ...vertical.filter((b) => b.org_type === (org && org.organization_type)), ...extra].map((b) => b.content).join('\n\n');
+    res.json(letters.render({ subject_template: subj, blocks: useBlocks, context, orgBlock }));
+  }));
+
+  // ── Outreach ───────────────────────────────────────────────────────────────
+  app.get('/api/pr/outreach', adminOnly, h(async (req, res) => res.json(await outreach.list(req.query))));
+  app.get('/api/pr/outreach/:id', adminOnly, h(async (req, res) => {
+    const m = await outreach.get(Number(req.params.id));
+    if (!m) return res.status(404).json({ error: 'not found' });
+    res.json(m);
+  }));
+  app.get('/api/pr/outreach/readiness/:personId', adminOnly, h(async (req, res) =>
+    res.json(await outreach.readiness(Number(req.params.personId)))));
+  app.post('/api/pr/outreach/draft', adminOnly, h(async (req, res) =>
+    res.json(await outreach.generateDraft({ ...(req.body || {}), actor: actorOf(req) }))));
+  app.put('/api/pr/outreach/:id', adminOnly, h(async (req, res) =>
+    res.json(await outreach.updateDraft(Number(req.params.id), { ...(req.body || {}), actor: actorOf(req) }))));
+  app.post('/api/pr/outreach/:id/approve', adminOnly, h(async (req, res) =>
+    res.json(await outreach.approve(Number(req.params.id), actorOf(req)))));
+  app.post('/api/pr/outreach/:id/provider-draft', adminOnly, h(async (req, res) =>
+    res.json(await outreach.createProviderDraft(Number(req.params.id), actorOf(req)))));
+  app.post('/api/pr/outreach/:id/send', adminOnly, h(async (req, res) =>
+    res.json(await outreach.send(Number(req.params.id), { confirm: (req.body || {}).confirm === true, actor: actorOf(req) }))));
+  app.post('/api/pr/outreach/:id/status', adminOnly, h(async (req, res) =>
+    res.json(await outreach.transition(Number(req.params.id), (req.body || {}).status, actorOf(req), (req.body || {}).detail))));
+
+  // ── Inbox & replies ────────────────────────────────────────────────────────
+  app.get('/api/pr/inbox', adminOnly, h(async (_q, res) => res.json(await replies.inbox({}))));
+  app.get('/api/pr/inbox/thread/:threadId', adminOnly, h(async (req, res) => res.json({ messages: await replies.thread(req.params.threadId) })));
+  app.post('/api/pr/inbox/sync', adminOnly, h(async (req, res) => res.json(await replies.syncThreads({ actor: actorOf(req) }))));
+  app.post('/api/pr/inbox/reply', adminOnly, h(async (req, res) => res.json(await replies.composeReply({ ...(req.body || {}), actor: actorOf(req) }))));
+  app.post('/api/pr/inbox/referral', adminOnly, h(async (req, res) => res.json(await replies.processReferral({ ...(req.body || {}), actor: actorOf(req) }))));
+
+  // ── Tasks ──────────────────────────────────────────────────────────────────
+  app.get('/api/pr/tasks', adminOnly, h(async (req, res) => res.json({ rows: await tasksSvc.list(req.query) })));
+  app.post('/api/pr/tasks', adminOnly, h(async (req, res) => res.json(await tasksSvc.create({ ...(req.body || {}), actor: actorOf(req) }))));
+  app.put('/api/pr/tasks/:id', adminOnly, h(async (req, res) => res.json(await tasksSvc.setStatus(Number(req.params.id), (req.body || {}).status, actorOf(req)))));
+
+  // ── Suppression ────────────────────────────────────────────────────────────
+  app.get('/api/pr/suppression', adminOnly, h(async (req, res) => res.json(await suppression.list(req.query))));
+  app.post('/api/pr/suppression', adminOnly, h(async (req, res) => res.json(await suppression.add({ ...(req.body || {}), actor: actorOf(req) }))));
+  app.delete('/api/pr/suppression/:id', adminOnly, h(async (req, res) => res.json(await suppression.remove(Number(req.params.id), actorOf(req)))));
+  app.get('/api/pr/suppression/check', adminOnly, h(async (req, res) => res.json(await suppression.isSuppressed({ email: req.query.email, person_id: req.query.person_id }))));
+
+  // ── Settings, gate, Arizona unlock ─────────────────────────────────────────
+  app.get('/api/pr/settings', adminOnly, h(async (_q, res) => res.json(await settings.getAll())));
+  app.put('/api/pr/settings/:key', adminOnly, h(async (req, res) =>
+    res.json(await settings.set(req.params.key, (req.body || {}).value, actorOf(req)))));
+  app.post('/api/pr/gate/check', adminOnly, h(async (_q, res) => res.json(await settings.californiaGate())));
+  app.post('/api/pr/gate/unlock-arizona', adminOnly, h(async (req, res) =>
+    res.json(await settings.unlockArizona({ actor: actorOf(req), override: (req.body || {}).override === true }))));
+
+  // ── Audit ──────────────────────────────────────────────────────────────────
+  app.get('/api/pr/audit', adminOnly, h(async (req, res) => res.json({ rows: await auditSvc.auditTrail(req.query) })));
+  app.get('/api/pr/activities', adminOnly, h(async (req, res) => res.json({ rows: await auditSvc.recent(req.query) })));
+
+  // ── Import / export ────────────────────────────────────────────────────────
+  app.post('/api/pr/import/preview', adminOnly, h(async (req, res) => {
+    const { csv, columnMap, kind } = req.body || {};
+    const p = importexport.preview({ csv, columnMap, kind });
+    delete p.all; // preview response stays small
+    res.json(p);
+  }));
+  app.post('/api/pr/import/run', adminOnly, h(async (req, res) =>
+    res.json(await importexport.runImport({ ...(req.body || {}), actor: actorOf(req) }))));
+  app.post('/api/pr/import/:id/reverse', adminOnly, h(async (req, res) =>
+    res.json(await importexport.reverse(Number(req.params.id), actorOf(req)))));
+  app.get('/api/pr/import/batches', adminOnly, h(async (_q, res) => res.json({ rows: await importexport.listBatches() })));
+  const sendCSV = (res, name, csv) => res.type('text/csv').set('Content-Disposition', `attachment; filename="${name}"`).send(csv);
+  app.get('/api/pr/export/organizations', adminOnly, h(async (req, res) => sendCSV(res, 'pr-organizations.csv', await importexport.exportOrganizations(req.query))));
+  app.get('/api/pr/export/people', adminOnly, h(async (req, res) => sendCSV(res, 'pr-people.csv', await importexport.exportPeople(req.query))));
+  app.get('/api/pr/export/suppression', adminOnly, h(async (_q, res) => sendCSV(res, 'pr-suppression.csv', await importexport.exportSuppression())));
+  app.get('/api/pr/export/outreach', adminOnly, h(async (req, res) => sendCSV(res, 'pr-outreach-history.csv', await importexport.exportOutreachHistory(req.query))));
+  app.get('/api/pr/export/evidence', adminOnly, h(async (req, res) => sendCSV(res, 'pr-source-evidence.csv', await importexport.exportSourceEvidence(req.query.entity_type))));
+
+  // ── Admin shells ───────────────────────────────────────────────────────────
+  app.get('/admin/pr-intelligence', adminOnly, (_q, r) => sendPage(r, path.join(SHELL_DIR, 'index.html')));
+  for (const page of PAGES.filter((p) => p !== 'index')) {
+    app.get('/admin/pr-intelligence/' + page, adminOnly, (_q, r) => sendPage(r, path.join(SHELL_DIR, page + '.html')));
+  }
+};

← 14c23d86 pr-intelligence: lib (geo/taxonomy/normalize/dedupe/scoring/  ·  back to Rentv 2026  ·  pr-intelligence: mount /api/pr/* + /admin/pr-intelligence sh 762a11ea →