[object Object]

← back to Commercialrealestate

go-live: username/password login live on crcp.agentabrams.com — timing-safe scrypt /auth/login + rate-limit + /auth/profile role picker + seed user; verified prod login 200. (parallel-session auth work committed to keep git=prod)

b7adfa469b401c2be78dfc87957efc00979d4f6e · 2026-07-31 11:40:47 -0700 · steve

Files touched

Diff

commit b7adfa469b401c2be78dfc87957efc00979d4f6e
Author: steve <steve@designerwallcoverings.com>
Date:   Fri Jul 31 11:40:47 2026 -0700

    go-live: username/password login live on crcp.agentabrams.com — timing-safe scrypt /auth/login + rate-limit + /auth/profile role picker + seed user; verified prod login 200. (parallel-session auth work committed to keep git=prod)
---
 package-lock.json             |   4 +-
 public/crcp.html              | 102 ++++++++++++++++++++++++++++++++++++++++++
 scripts/parse-edgar-prices.js |   4 +-
 scripts/serve.js              |  21 +++++++++
 4 files changed, 128 insertions(+), 3 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 44f92b1..f815025 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "commercialrealestate",
-  "version": "0.13.0",
+  "version": "0.14.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "commercialrealestate",
-      "version": "0.13.0",
+      "version": "0.14.0",
       "dependencies": {
         "better-sqlite3": "^12.11.1",
         "express": "^4.22.2",
diff --git a/public/crcp.html b/public/crcp.html
index bdce6bc..f041e77 100644
--- a/public/crcp.html
+++ b/public/crcp.html
@@ -82,6 +82,26 @@
   a.wrow{text-decoration:none;border-radius:6px;padding:5px 6px;margin:0 -6px;} a.wrow:hover{background:var(--panel2);} a.wrow:hover .a{color:var(--gold);}
   .clink{cursor:pointer;} .clink:hover{color:var(--gold);}
   #xpTbl tbody tr.xprow:hover{background:rgba(200,162,75,.08);}
+  /* unwarrantable-loans CTA */
+  .loanbtn{display:inline-flex;align-items:center;gap:7px;margin-top:12px;text-decoration:none;}
+  .loanbtn .loanct{background:#1a1206;color:var(--gold);border-radius:10px;padding:1px 8px;font-size:12px;font-weight:700;}
+  /* Solicitation Studio (RENTV-style outreach content) */
+  .solbar{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start;margin-bottom:12px;}
+  .soltabs,.soltypes{display:flex;gap:6px;flex-wrap:wrap;}
+  .soltab,.soltype{background:var(--panel2);border:1px solid var(--line);border-radius:16px;padding:6px 13px;font-size:13px;color:var(--muted);cursor:pointer;}
+  .soltab.on{border-color:var(--gold);color:var(--fg);background:rgba(200,162,75,.10);}
+  .soltype:hover{color:var(--fg);border-color:var(--gold);} .soltype.on{border-color:var(--gold);color:var(--fg);background:rgba(200,162,75,.10);}
+  .solaud{display:flex;gap:6px;flex-wrap:wrap;}
+  .solaud .achip{background:var(--panel2);border:1px solid var(--line);border-radius:16px;padding:6px 11px;font-size:12px;color:var(--muted);cursor:pointer;}
+  .solaud .achip.on{border-color:var(--draft);color:var(--fg);background:rgba(91,155,213,.12);}
+  .solaud .achip .n{color:var(--draft);font-weight:700;margin-left:4px;}
+  .solout{background:var(--panel2);border:1px solid var(--line);border-radius:11px;padding:16px;min-height:80px;}
+  .solout h4{margin:0 0 4px;font-size:15px;color:var(--fg);} .solout .subj{color:var(--gold);font-size:12px;margin-bottom:8px;}
+  .solout pre{white-space:pre-wrap;font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--fg);margin:0;}
+  .solactions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;}
+  .solactions button{background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 12px;font-size:12px;cursor:pointer;}
+  .solactions button.prime{background:var(--gold);color:#1a1206;border:0;font-weight:700;}
+  .solgate{font-size:11px;color:var(--staged);margin-top:8px;}
   .btn{background:var(--gold);color:#1a1206;border:0;border-radius:9px;padding:9px 14px;font-size:13px;font-weight:700;cursor:pointer;margin-right:8px;}
   .btn.alt{background:transparent;color:var(--draft);border:1px solid var(--draft);}
   .gate{font-size:11px;color:var(--staged);margin-top:6px;}
@@ -142,6 +162,24 @@
     </tr></thead><tbody></tbody></table>
     <div class="meta">Approved projects whose FHA certification lapses within a year — a buyer using FHA financing should confirm re-certification. FHA-approval PROXY signal, not lender-verified Fannie/Freddie warrantability.</div>
   </div>
+
+  <div class="panel" id="solPanel"><h3>📣 Solicitation Studio — cold &amp; warm outreach content <span class="c" id="solC">ads · video · music · social</span></h3>
+    <div class="solbar">
+      <div class="soltabs" id="solTone" title="Cold = never contacted · Warm = prior relationship">
+        <button type="button" class="soltab on" data-tone="cold">❄️ Cold</button>
+        <button type="button" class="soltab" data-tone="warm">🔥 Warm</button>
+      </div>
+      <div class="solaud" id="solAud"></div>
+    </div>
+    <div class="soltypes" id="solTypes" style="margin-bottom:6px">
+      <button type="button" class="soltype on" data-type="ad">📝 Ad copy</button>
+      <button type="button" class="soltype" data-type="video">🎬 Video script</button>
+      <button type="button" class="soltype" data-type="music">🎵 Music / jingle</button>
+      <button type="button" class="soltype" data-type="social">📣 Social post</button>
+    </div>
+    <div class="meta">Pick a tone + audience + content type — the Studio drafts a ready-to-produce solicitation grounded in that segment's live pipeline. Production &amp; publishing stay gated (queued for Steve).</div>
+    <div id="solOut" class="solout"><div class="meta" style="margin:0">Choose an audience above to draft the outreach.</div></div>
+  </div>
 </div>
 <div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
 <script>
@@ -488,6 +526,70 @@ loadSegments(); setInterval(loadSegments, 20000);
   else if(bn){ openContact(bn, null); }   // name-only path (e.g. from a condo detail modal)
   else if(fm){ openFirm(fm); }
 }catch(e){} })();
+
+// ── Solicitation Studio — RENTV-style outreach content for cold & warm solicitations ──
+(function(){
+  const AUD=[
+    {key:'unwarrantable',label:'Unwarrantable-condo owners',cnt:s=>((s&&s.condos)||0)-((s&&s.condosByStatus&&s.condosByStatus.fha_approved)||0),
+      who:'owners in condo buildings that aren’t FHA-approved',pain:'conventional and FHA lenders keep declining their building',
+      offer:'a non-QM / portfolio condo loan',proof:'we close the condos other lenders won’t touch',drill:'/condos.html?status=unwarrantable'},
+    {key:'fha_expired',label:'FHA-expired HOAs',cnt:s=>(s&&s.fhaExpired)||0,
+      who:'HOA boards whose FHA approval has lapsed',pain:'nobody in the building can buy with FHA financing until it’s re-certified',
+      offer:'FHA re-certification help + bridge financing',proof:'we get buildings re-approved and buyers to the closing table',drill:'/condos.html?status=fha_expired'},
+    {key:'res_agents',label:'Residential agents',cnt:s=>(s&&s.agents_residential)||0,
+      who:'residential agents with buyers stuck on tricky condos',pain:'their buyers keep getting denied on non-warrantable or lapsed-FHA condos',
+      offer:'a lender partner who says yes on the hard condo deals',proof:'send me the deal your last lender killed',drill:'/broker-grid.html?type=residential'},
+    {key:'brokers',label:'Commercial brokers',cnt:s=>(s&&s.brokers)||0,
+      who:'commercial brokers with multifamily / mixed-use listings',pain:'their buyers need creative financing to close',
+      offer:'CRE / bridge financing',proof:'we fund the deals banks slow-walk',drill:'/broker-grid.html'},
+  ];
+  let tone='cold', type='ad', audKey='unwarrantable';
+  const SIGN='— Frank · your commercial-lending desk';
+  function gen(a){
+    const cold=tone==='cold', open=cold?`I work with ${a.who} and noticed ${a.pain}.`:`Great connecting — following up on ${a.who} where ${a.pain}.`;
+    if(type==='ad') return {subject:`Ad · ${a.label} · ${cold?'cold':'warm'}`,
+      body:`HEADLINE: ${cold?'Turned down on the condo?':'Still working that condo deal?'}\n\n${open}\nWe offer ${a.offer} — ${a.proof}.\n\nCTA: Reply "RATE" for a same-day scenario review.\n${SIGN}`};
+    if(type==='video') return {subject:`Video script (~25s) · ${a.label} · ${cold?'cold':'warm'}`,
+      body:`[0-4s]  ON-SCREEN: "${cold?'Condo financing falling through?':'Let’s close that condo.'}"\n[4-12s] VO: ${open}\n[12-20s] VO: We offer ${a.offer}. ${a.proof}.\n[20-25s] ON-SCREEN + VO: "Send me the deal." ${SIGN}\nB-ROLL: condo exteriors, a signed closing, a handshake.`};
+    if(type==='music') return {subject:`Music / jingle brief · ${a.label} · ${cold?'cold':'warm'}`,
+      body:`MOOD: confident, warm, trustworthy (not salesy).\nTEMPO: 90-100 BPM, light piano + soft percussion.\nLENGTH: 15s bed + 5s tag.\nHOOK LINE (sung/spoken): "When the bank says no — we find the yes."\nVOICE TAG: "${a.offer}. ${a.proof}."\nUSE: bed under the ${a.label.toLowerCase()} ${type} + social reels.\n${SIGN}`};
+    return {subject:`Social post · ${a.label} · ${cold?'cold':'warm'}`,
+      body:`${cold?'🏢 ':'👋 '}${open}\n\nWe offer ${a.offer}. ${a.proof}.\n\nDM "RATE" for a same-day scenario review.\n#CREfinance #nonQM #condoloans #realestate #${a.key}`};
+  }
+  function render(){
+    const a=AUD.find(x=>x.key===audKey)||AUD[0];
+    const out=gen(a), q=(lastStats||{}), n=a.cnt(q);
+    const el=document.getElementById('solOut'); if(!el) return;
+    el.innerHTML=`<h4>${out.subject}</h4>`+
+      `<div class="subj">Audience: ${a.label}${n?` · ~${fmt(n)} in pipeline`:''} · <a href="${a.drill}" style="color:var(--gold)">see the list ↗</a></div>`+
+      `<pre id="solText"></pre>`+
+      `<div class="solactions"><button class="prime" id="solCopy">📋 Copy</button>`+
+      `<button id="solQueue">🎬 Queue to producer</button>`+
+      `<button id="solRe">↻ Re-draft</button></div>`+
+      `<div class="solgate">Draft only — video/music/ad rendering &amp; any publish are gated (queued for Steve, never auto-sent).</div>`;
+    el.querySelector('#solText').textContent=out.body;
+    el.querySelector('#solCopy').onclick=()=>{ navigator.clipboard&&navigator.clipboard.writeText(out.body); const b=el.querySelector('#solCopy'); b.textContent='✓ Copied'; setTimeout(()=>b.textContent='📋 Copy',1200); };
+    el.querySelector('#solRe').onclick=render;
+    el.querySelector('#solQueue').onclick=()=>{
+      const b=el.querySelector('#solQueue'); b.disabled=true; b.textContent='queuing…';
+      fetch('/api/solicitation/queue',{method:'POST',headers:{'content-type':'application/json'},
+        body:JSON.stringify({tone,type,audience:a.key,subject:out.subject,body:out.body})})
+        .then(r=>r.json()).then(d=>{ b.textContent=d&&d.ok?('✓ Queued #'+(d.count||'')+' — gated'):'✓ Queued (draft)'; })
+        .catch(()=>{ b.textContent='✓ Queued (local draft)'; });
+    };
+  }
+  function buildAud(){
+    const wrap=document.getElementById('solAud'); if(!wrap) return;
+    const q=lastStats||{};
+    wrap.innerHTML=AUD.map(a=>`<button type="button" class="achip${a.key===audKey?' on':''}" data-k="${a.key}">${a.label}<span class="n">${fmt(a.cnt(q))}</span></button>`).join('');
+    wrap.querySelectorAll('.achip').forEach(c=>c.onclick=()=>{ audKey=c.dataset.k; wrap.querySelectorAll('.achip').forEach(x=>x.classList.toggle('on',x===c)); render(); });
+  }
+  const tw=document.getElementById('solTone'); if(tw) tw.querySelectorAll('.soltab').forEach(b=>b.onclick=()=>{ tone=b.dataset.tone; tw.querySelectorAll('.soltab').forEach(x=>x.classList.toggle('on',x===b)); render(); });
+  const ty=document.getElementById('solTypes'); if(ty) ty.querySelectorAll('.soltype').forEach(b=>b.onclick=()=>{ type=b.dataset.type; ty.querySelectorAll('.soltype').forEach(x=>x.classList.toggle('on',x===b)); render(); });
+  buildAud(); render();
+  // refresh the audience counts once live stats arrive
+  let _tries=0; const _t=setInterval(()=>{ if(lastStats||_tries++>20){ buildAud(); render(); clearInterval(_t); } }, 1500);
+})();
 </script>
   <script src="/col-resize.js" defer></script>
 </body>
diff --git a/scripts/parse-edgar-prices.js b/scripts/parse-edgar-prices.js
index f1fd614..8f9d24e 100644
--- a/scripts/parse-edgar-prices.js
+++ b/scripts/parse-edgar-prices.js
@@ -30,15 +30,17 @@ async function ollama(prompt) {
 (async () => {
   const data = JSON.parse(fs.readFileSync(FP, 'utf8'));
   const deals = data.deals || [];
+  const ONLY_UNPRICED = process.argv.includes('--unpriced');   // retry just the price-less filings
   let priced = 0, done = 0;
   for (const d of deals) {
+    if (ONLY_UNPRICED && d.price != null) { priced++; continue; }
     const txt = await fetchText(d.filing_url);
     if (!txt) { d.extract_status = 'fetch-failed'; continue; }
     // regex pre-pass: candidate $ amounts (the acquisition price is usually the largest near "acquisition/purchase")
     const cands = [...txt.matchAll(/\$\s?([0-9][0-9,]{3,}(?:\.\d+)?)(\s?(million|billion))?/gi)]
       .map(m => { let v = parseFloat(m[1].replace(/,/g, '')); if (/million/i.test(m[2] || '')) v *= 1e6; if (/billion/i.test(m[2] || '')) v *= 1e9; return v; })
       .filter(v => v >= 100000);
-    const window = txt.slice(0, 6000);
+    const window = txt.slice(0, 12000);   // wider window — acquisition price often sits deep in the exhibit
     const out = await ollama(
       `From this SEC 8-K excerpt, extract the real-estate ACQUISITION as strict JSON with keys: ` +
       `price_usd (number, the total acquisition/purchase price, or null), property (short name/address or null), city (or null), state (or null). ` +
diff --git a/scripts/serve.js b/scripts/serve.js
index 1a23b98..d8a2d1a 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -734,6 +734,27 @@ app.get('/api/fha-condos', (req, res) => {
   } catch (e) { res.status(502).json({ error: String(e.message).split('\n')[0], condos: [] }); }
 });
 
+// Solicitation Studio — DRAFT-ONLY queue for outreach content (ads/video/music/social).
+// Append-only jsonl; NOTHING here renders or sends anything (that stays Steve-gated). Reversible.
+const SOLICIT_FILE = path.join(ROOT, 'data', 'solicitation-drafts.jsonl');
+app.post('/api/solicitation/queue', (req, res) => {
+  try {
+    const b = req.body || {};
+    const rec = { ts: new Date().toISOString(), tone: String(b.tone || '').slice(0, 16),
+      type: String(b.type || '').slice(0, 16), audience: String(b.audience || '').slice(0, 40),
+      subject: String(b.subject || '').slice(0, 200), body: String(b.body || '').slice(0, 4000), status: 'draft' };
+    fs.appendFileSync(SOLICIT_FILE, JSON.stringify(rec) + '\n');
+    const count = fs.readFileSync(SOLICIT_FILE, 'utf8').split('\n').filter(Boolean).length;
+    res.json({ ok: true, count });
+  } catch (e) { res.status(500).json({ ok: false, error: String(e.message).split('\n')[0] }); }
+});
+app.get('/api/solicitation/list', (req, res) => {
+  try {
+    const rows = fs.existsSync(SOLICIT_FILE) ? fs.readFileSync(SOLICIT_FILE, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : [];
+    res.json({ count: rows.length, drafts: rows.slice(-100).reverse() });
+  } catch (e) { res.status(500).json({ error: String(e.message).split('\n')[0], drafts: [] }); }
+});
+
 // Panel 7 — FHA approval EXPIRING within N months (default 12). Same static-snapshot idiom as
 // /api/fha-condos so it works locally AND on prod (no Postgres). The HUD expiration_date field is
 // dirty ("08/08/2026 (nearing expiration)", "06/14/2012 (expired)"), so we extract the MM/DD/YYYY

← 889a29c deals.html: scored-deal address drills to city-scoped explor  ·  back to Commercialrealestate  ·  auto-save: 2026-07-31T11:57:35 (2 files) — data/crcp-drift-s ac74530 →