[object Object]

← back to Rentv 2026

Deal Desk: viewer for an uncovered closed CRE deal — chat-adjust any field + generate social (local LLM $0); Gemini find endpoint

6d2fbcd303dbfc71ac6f2c0dc96bd840f6005cee · 2026-08-05 11:29:35 -0700 · steve

Files touched

Diff

commit 6d2fbcd303dbfc71ac6f2c0dc96bd840f6005cee
Author: steve <steve@designerwallcoverings.com>
Date:   Wed Aug 5 11:29:35 2026 -0700

    Deal Desk: viewer for an uncovered closed CRE deal — chat-adjust any field + generate social (local LLM $0); Gemini find endpoint
---
 public/admin/pr-intelligence/deal-desk.html | 126 ++++++++++++++++++++++++++++
 public/admin/pr-intelligence/pr-shared.js   |   1 +
 server.js                                   |  68 +++++++++++++++
 3 files changed, 195 insertions(+)

diff --git a/public/admin/pr-intelligence/deal-desk.html b/public/admin/pr-intelligence/deal-desk.html
new file mode 100644
index 00000000..87c9ad94
--- /dev/null
+++ b/public/admin/pr-intelligence/deal-desk.html
@@ -0,0 +1,126 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Deal Desk</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+<style>
+  .dd-wrap{display:grid;grid-template-columns:1fr 1fr;gap:20px;align-items:start}
+  @media(max-width:960px){.dd-wrap{grid-template-columns:1fr}}
+  .dd-card{border:1px solid var(--line,#e4e7ea);border-radius:10px;background:var(--card,#fff);padding:16px 18px;margin:0 0 16px}
+  .dd-card h3{margin:0 0 4px;font-size:14px} .dd-card .sub{font-size:12px;color:var(--muted,#5b636b);margin:0 0 10px}
+  .dd-field{margin:0 0 10px} .dd-field label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted,#5b636b);margin:0 0 3px}
+  .dd-field input,.dd-field select,.dd-field textarea{width:100%;box-sizing:border-box;font:inherit;font-size:13px;padding:7px 9px;border:1px solid var(--line,#d7dde3);border-radius:8px;background:#fff}
+  .dd-field textarea{min-height:80px;resize:vertical}
+  .dd-two{display:flex;gap:10px}.dd-two>*{flex:1}
+  .dd-cover{font-size:12px;border-radius:6px;padding:6px 10px;margin:0 0 10px}
+  .dd-cover.ok{background:#e7f6ec;color:#1c7a3e} .dd-cover.bad{background:#fff2e0;color:#8a5a00}
+  .dd-chat{max-height:220px;overflow:auto;border:1px solid var(--line,#eee);border-radius:8px;padding:8px 10px;margin:0 0 8px;background:var(--wash,#f7f8fa)}
+  .dd-msg{font-size:13px;margin:4px 0;line-height:1.4} .dd-msg.u{text-align:right} .dd-msg.u b{background:#0a66c2;color:#fff;padding:3px 8px;border-radius:8px;display:inline-block} .dd-msg.a b{background:#eef1f4;padding:3px 8px;border-radius:8px;display:inline-block;font-weight:500}
+  .dd-social textarea{min-height:90px} .cs-cost{display:inline-block;background:#e7f6ec;color:#1c7a3e;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:700}
+  .cc{font-size:11px;color:var(--muted,#5b636b);margin-top:4px}
+  .spin{display:inline-block;width:13px;height:13px;border:2px solid #c9d3dd;border-top-color:#0a6cff;border-radius:50%;animation:sp .7s linear infinite;vertical-align:-2px}@keyframes sp{to{transform:rotate(360deg)}}
+</style>
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('deal-desk', 'Deal Desk',
+    'A closed CRE deal RENTV hasn’t covered — adjust any field by typing an instruction, then spin social from it. Seeded with a real, verified, uncovered deal; the local model ($0) handles edits + social.');
+  c.insertAdjacentHTML('beforeend', '<div class="dd-wrap"><div id="left"></div><div id="right"></div></div>');
+  const $ = id => document.getElementById(id);
+
+  // Seeded with a REAL uncovered deal (verified not in RENTV's news archive), sourced 2026-08.
+  let deal = {
+    headline: "BlackRock Buys Camden's 11-Property SoCal Apartment Portfolio for $1.63B",
+    txn_type: "Sale", property_type: "Multifamily",
+    address: "11-property portfolio", city: "LA / Orange / Riverside / San Diego counties", state: "CA",
+    price: 1630000000, price_label: "$1.63B", units: 3620, sqft: 0,
+    buyer: "A BlackRock-managed fund", seller: "Camden Property Trust", broker: "JLL Capital Markets",
+    close_date: "2026-08-05",
+    summary: "A BlackRock-managed fund acquired Camden Property Trust's 11-property, 3,620-unit Southern California apartment portfolio for $1.63 billion — the largest U.S. multifamily sale since June 2024. The 96%-occupied portfolio spans Los Angeles, Orange, Riverside and San Diego counties; JLL Capital Markets represented Camden and arranged $566.6 million in agency acquisition financing covering seven of the properties.",
+  };
+  let coveredNote = null; // seeded deal is verified uncovered
+
+  const F = [['headline', 'Headline', 'text'], ['txn_type', 'Transaction', 'sel:Sale,Financing,Lease'], ['property_type', 'Property type', 'sel:Multifamily,Office,Industrial,Retail,Hotel,Mixed-Use,Land'],
+    ['address', 'Address', 'text'], ['city', 'City / area', 'text'], ['state', 'State', 'text'],
+    ['price_label', 'Price', 'text'], ['units', 'Units', 'num'], ['sqft', 'Sq ft', 'num'],
+    ['buyer', 'Buyer', 'text'], ['seller', 'Seller', 'text'], ['broker', 'Broker', 'text'], ['close_date', 'Close date', 'text'],
+    ['summary', 'Summary', 'area']];
+
+  function renderDeal() {
+    $('left').innerHTML = `
+      <div class="dd-card">
+        <div style="display:flex;justify-content:space-between;align-items:center"><h3>The deal</h3><button class="btn sm" id="find" title="Uses Gemini web search">🔎 Find another uncovered deal</button></div>
+        <div class="cc" id="findmeta"></div>
+        ${coveredNote ? `<div class="dd-cover bad">⚠ RENTV may already cover this: “${PR.esc(coveredNote.title)}”</div>` : `<div class="dd-cover ok">✅ Verified: not in RENTV’s news archive (uncovered).</div>`}
+        ${F.map(([k, label, type]) => {
+          if (type === 'area') return `<div class="dd-field"><label>${label}</label><textarea data-k="${k}">${PR.esc(deal[k] || '')}</textarea></div>`;
+          if (type && type.startsWith('sel:')) return `<div class="dd-field"><label>${label}</label><select data-k="${k}">${type.slice(4).split(',').map(o => `<option ${o === deal[k] ? 'selected' : ''}>${o}</option>`).join('')}</select></div>`;
+          return `<div class="dd-field"><label>${label}</label><input data-k="${k}" ${type === 'num' ? 'type="number"' : ''} value="${PR.esc(deal[k] == null ? '' : deal[k])}"></div>`;
+        }).join('')}
+      </div>`;
+    // field edits write straight back to the deal object
+    $('left').querySelectorAll('[data-k]').forEach(el => el.addEventListener('input', () => { deal[el.dataset.k] = el.type === 'number' ? Number(el.value) : el.value; }));
+    $('find').addEventListener('click', findAnother);
+  }
+
+  async function findAnother() {
+    $('findmeta').innerHTML = '<span class="spin"></span> Searching the web for an uncovered closed deal…';
+    try {
+      const j = await PR.api('/../dealdesk/find', { method: 'POST', body: { avoid: deal.headline } });
+      if (j.deal && j.deal.headline) { deal = j.deal; coveredNote = j.covered; renderDeal(); $('findmeta').innerHTML = `<span class="cs-cost">${PR.esc(j.cost || '')}</span> · sources: ${(j.sources || []).length}`; }
+      else $('findmeta').innerHTML = '<span class="warn">No deal returned.</span>';
+    } catch (e) { $('findmeta').innerHTML = `<span class="warn" style="color:#b23a1e">${PR.esc(e.message)}</span> — (Gemini search may be out of credits; edit the seeded deal via chat instead.)`; }
+  }
+
+  // ── Right: chat-adjust + social ─────────────────────────────────────────────
+  $('right').innerHTML = `
+    <div class="dd-card">
+      <h3>Adjust by chat</h3><div class="sub">Type a plain-English change — "buyer was actually Blackstone", "price is $1.7B", "make the summary punchier". Local model · $0.</div>
+      <div class="dd-chat" id="chat"><div class="dd-msg a"><b>Tell me what to fix on the deal and I’ll update the fields.</b></div></div>
+      <div class="dd-two"><input id="msg" placeholder="e.g. the broker was CBRE, not JLL" style="font:inherit;font-size:13px;padding:8px 10px;border:1px solid var(--line,#d7dde3);border-radius:8px"><button class="btn" id="send">Send</button></div>
+    </div>
+    <div class="dd-card dd-social">
+      <div style="display:flex;justify-content:space-between;align-items:center"><h3>Social from this deal</h3><button class="btn" id="gensocial">✨ Generate</button></div>
+      <div class="cc" id="socmeta"></div>
+      <div class="dd-field"><label>LinkedIn <a class="cs-copy" data-copy="s_li">📋</a></label><textarea id="s_li"></textarea></div>
+      <div class="dd-field"><label>X / Twitter <a class="cs-copy" data-copy="s_x">📋</a></label><textarea id="s_x"></textarea></div>
+      <div class="dd-field"><label>Instagram <a class="cs-copy" data-copy="s_ig">📋</a></label><textarea id="s_ig"></textarea></div>
+    </div>`;
+
+  function addMsg(who, text) { const box = $('chat'); box.insertAdjacentHTML('beforeend', `<div class="dd-msg ${who}"><b>${PR.esc(text)}</b></div>`); box.scrollTop = box.scrollHeight; }
+  async function sendChat() {
+    const m = $('msg').value.trim(); if (!m) return;
+    addMsg('u', m); $('msg').value = ''; $('send').disabled = true; addMsg('a', '…');
+    try {
+      const j = await PR.api('/../dealdesk/chat', { method: 'POST', body: { deal, message: m } });
+      $('chat').lastElementChild.querySelector('b').textContent = j.reply || 'Updated.';
+      if (j.deal) { deal = j.deal; renderDeal(); }
+    } catch (e) { $('chat').lastElementChild.querySelector('b').textContent = '⚠ ' + e.message; }
+    finally { $('send').disabled = false; }
+  }
+  $('send').addEventListener('click', sendChat);
+  $('msg').addEventListener('keydown', e => { if (e.key === 'Enter') sendChat(); });
+
+  $('gensocial').addEventListener('click', () => PR.guard(async () => {
+    $('gensocial').disabled = true; $('socmeta').innerHTML = '<span class="spin"></span> Writing posts from the deal…';
+    try {
+      const j = await PR.api('/../dealdesk/social', { method: 'POST', body: { deal } });
+      $('s_li').value = j.linkedin || ''; $('s_x').value = j.x || ''; $('s_ig').value = j.instagram || '';
+      $('socmeta').innerHTML = `<span class="cs-cost">${PR.esc(j.cost || '$0')}</span> · posts generated`;
+    } finally { $('gensocial').disabled = false; }
+  }, null));
+
+  document.addEventListener('click', (e) => {
+    const a = e.target.closest('.cs-copy'); if (!a) return; e.preventDefault();
+    const el = $(a.getAttribute('data-copy')); if (!el || !navigator.clipboard) return;
+    navigator.clipboard.writeText(el.value || '').then(() => { const o = a.textContent; a.textContent = '✓'; setTimeout(() => a.textContent = o, 1200); });
+  });
+
+  renderDeal();
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/pr-shared.js b/public/admin/pr-intelligence/pr-shared.js
index dfdd0074..8d641dc2 100644
--- a/public/admin/pr-intelligence/pr-shared.js
+++ b/public/admin/pr-intelligence/pr-shared.js
@@ -11,6 +11,7 @@
     ['review', '✅', 'Review Queue'],
     ['__sepC', '', 'Content'],
     ['content-studio', '📰', 'Content Studio'],
+    ['deal-desk', '🏢', 'Deal Desk'],
     ['content-drafts', '📝', 'Drafts'],
     ['linkedin-posting', '📣', 'LinkedIn Posting'],
     ['__sep1', '', 'Outreach'],
diff --git a/server.js b/server.js
index d8d7510d..3f90f565 100644
--- a/server.js
+++ b/server.js
@@ -540,6 +540,74 @@ app.post('/api/content/email', adminOnly, async (req, res) => {
   } catch (e) { return res.status(502).json({ ok: false, error: String(e.message || e).slice(0, 160) }); }
 });
 
+// ── Deal Desk: find a closed CRE deal RENTV hasn't covered, chat-adjust it, spin social ──
+//    /find  → Gemini (grounded, paid) surfaces a real recent Western-U.S. closed deal + flags
+//             whether the news archive already covers it. /chat → local LLM ($0) edits the deal
+//             from a plain-English instruction. /social → local LLM ($0) writes posts from the deal.
+async function geminiGrounded(query) {
+  if (!GEMINI_KEY) return { text: '', pages: [] };
+  try {
+    const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ contents: [{ parts: [{ text: query }] }], tools: [{ google_search: {} }] }),
+      signal: AbortSignal.timeout(40000),
+    });
+    if (!r.ok) return { text: '', pages: [] };
+    const j = await r.json();
+    const text = j.candidates?.[0]?.content?.parts?.map(p => p.text || '').join('') || '';
+    const pages = (j.candidates?.[0]?.groundingMetadata?.groundingChunks || []).map(c => c.web && c.web.uri).filter(Boolean).slice(0, 6);
+    return { text, pages };
+  } catch { return { text: '', pages: [] }; }
+}
+const grabJSON = (t) => { const m = String(t || '').match(/\{[\s\S]*\}/); if (!m) return null; try { return JSON.parse(m[0]); } catch { return null; } };
+app.post('/api/dealdesk/find', adminOnly, async (req, res) => {
+  if (!GEMINI_KEY) return res.status(503).json({ ok: false, error: 'GEMINI_API_KEY missing' });
+  const started = Date.now();
+  const avoid = clean((req.body || {}).avoid, 400); // titles already surfaced this session, to skip
+  const g = await geminiGrounded(`Find ONE real, recently CLOSED commercial real estate transaction in the Western U.S. (California, Arizona, Nevada, Oregon, Washington, Colorado) — a sale, financing, or major lease that actually closed in the last few weeks. It must be a REAL deal you can source. ${avoid ? 'Do NOT pick any of these already-used ones: ' + avoid + '. ' : ''}Return ONLY JSON:
+{"headline":"punchy deal headline","txn_type":"Sale|Financing|Lease","property_type":"Multifamily|Office|Industrial|Retail|Hotel|Mixed-Use|Land","address":"street address","city":"","state":"CA","price":0,"price_label":"$00M","units":0,"sqft":0,"buyer":"","seller":"","broker":"","close_date":"YYYY-MM-DD or approx","summary":"2-3 sentence factual summary of the deal"}`);
+  const deal = grabJSON(g.text) || {};
+  // coverage check against the deep news archive (title/address/parties overlap)
+  const arch = readJSON('news-archive.json', []); const items = Array.isArray(arch) ? arch : (arch.items || []);
+  const needle = [deal.address, deal.buyer, deal.seller, deal.headline].filter(Boolean).map(s => String(s).toLowerCase());
+  const covered = items.find(a => { const t = String(a.title || '').toLowerCase(); return needle.some(n => n.length > 8 && t.includes(n.split(',')[0].trim())); });
+  res.json({ ok: true, deal, covered: covered ? { title: covered.title, source: covered.source } : null, sources: g.pages, cost: '$0.005 (Gemini 1 search)', ms: Date.now() - started });
+});
+app.post('/api/dealdesk/chat', adminOnly, async (req, res) => {
+  const b = req.body || {}; const deal = b.deal || {}; const msg = clean(b.message, 600);
+  if (!msg) return res.status(400).json({ ok: false, error: 'message required' });
+  try {
+    const r = await fetch(OLLAMA_URL + '/api/generate', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ model: CONTENT_LLM, format: 'json', stream: false, options: { temperature: 0.2 },
+        prompt: `You are editing a commercial real estate DEAL record from a plain-English instruction. Apply the change and return the FULL updated record.
+Return ONLY JSON: {"deal":<the complete updated deal object, same keys as input>,"reply":"one short sentence describing what you changed"}
+CURRENT DEAL: ${JSON.stringify(deal)}
+INSTRUCTION: ${msg}` }),
+      signal: AbortSignal.timeout(90000),
+    });
+    if (!r.ok) return res.status(502).json({ ok: false, error: 'local LLM error' });
+    const j = await r.json(); const out = grabJSON(j.response) || {};
+    res.json({ ok: true, deal: out.deal || deal, reply: clean(out.reply, 200) || 'Updated.', cost: '$0 (local Ollama)' });
+  } catch (e) { res.status(502).json({ ok: false, error: String(e.message || e).slice(0, 160) }); }
+});
+app.post('/api/dealdesk/social', adminOnly, async (req, res) => {
+  const deal = (req.body || {}).deal || {};
+  try {
+    const r = await fetch(OLLAMA_URL + '/api/generate', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ model: CONTENT_LLM, format: 'json', stream: false, options: { temperature: 0.7 },
+        prompt: `Write RENTV social posts announcing this commercial real estate deal, in RENTV's professional CRE voice. Do NOT invent facts beyond the deal. Return ONLY JSON:
+{"linkedin":"2 short paragraphs + 2-3 hashtags","x":"under 270 chars with 1-2 hashtags","instagram":"punchy caption + 3-5 hashtags"}
+DEAL: ${JSON.stringify(deal)}` }),
+      signal: AbortSignal.timeout(90000),
+    });
+    if (!r.ok) return res.status(502).json({ ok: false, error: 'local LLM error' });
+    const j = await r.json(); const out = grabJSON(j.response) || {};
+    res.json({ ok: true, linkedin: clean(out.linkedin, 3000), x: clean(out.x, 600), instagram: clean(out.instagram, 2000), cost: '$0 (local Ollama)' });
+  } catch (e) { res.status(502).json({ ok: false, error: String(e.message || e).slice(0, 160) }); }
+});
+
 // Deep, date-ordered news archive — accumulates FAR beyond the rolling 30-item live feed.
 // SSOT = data/news-archive.json (seeded by scripts/build-news-archive.mjs, grown by pull-news).
 // Sorted newest-first by real publish date (falls back to first_seen). Admin-only (content tool).

← 3cdd23d7 chore: v0.19.0 (session close — Content Studio + photo track  ·  back to Rentv 2026  ·  Content Studio: Email-to-Boom now creates a Gmail DRAFT in s b1efd88a →