← back to Rentv
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
A public/admin/pr-intelligence/deal-desk.htmlM public/admin/pr-intelligence/pr-shared.jsM server.js
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
·
Content Studio: Email-to-Boom now creates a Gmail DRAFT in s b1efd88a →