[object Object]

← back to Commercialrealestate

P1 subscription layer: accounts (magic-link) + saved searches + watchlist + daily alert digest — crcp-accounts.js mounted in serve.js, deals-flow.html wired (sign-in pill, save/star), deal-alerts.js ($0 digest); launchd plist written not installed. Verified E2E: 632-match digest for a saved search.

52f46cab1cb0b30a271236ac2cb6b5ae0235284c · 2026-07-31 10:39:32 -0700 · steve

Files touched

Diff

commit 52f46cab1cb0b30a271236ac2cb6b5ae0235284c
Author: steve <steve@designerwallcoverings.com>
Date:   Fri Jul 31 10:39:32 2026 -0700

    P1 subscription layer: accounts (magic-link) + saved searches + watchlist + daily alert digest — crcp-accounts.js mounted in serve.js, deals-flow.html wired (sign-in pill, save/star), deal-alerts.js ($0 digest); launchd plist written not installed. Verified E2E: 632-match digest for a saved search.
---
 .gitignore                               |  2 +
 public/deals-flow.html                   | 22 +++++++-
 scripts/com.steve.crcp-deal-alerts.plist | 13 +++++
 scripts/crcp-accounts.js                 | 92 ++++++++++++++++++++++++++++++++
 scripts/deal-alerts.js                   | 54 +++++++++++++++++++
 5 files changed, 181 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index f9e7be8..e1fc793 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,5 @@ data/raw/*.pre-*.bak
 data/edgar-reit-deals.json
 data/deals-flow.json
 data/modal-opens.jsonl
+data/crcp-accounts.json
+data/deal-alerts-out/
diff --git a/public/deals-flow.html b/public/deals-flow.html
index 350ddfd..a9fe50e 100644
--- a/public/deals-flow.html
+++ b/public/deals-flow.html
@@ -46,9 +46,11 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
   <h1><b>Closed Deal Flow</b> · LA Commercial</h1>
   <span class="badge">100% public record</span>
   <div class="nav"><a href="/fha-leads.html">Assumable Leads</a><a href="/mls.html">Listings</a><a href="/">Explorer</a></div>
+  <span id="auth" style="margin-left:auto;font-size:12px;color:var(--blue);cursor:pointer">Sign in</span>
 </header>
 <div class="shell">
 <aside class="rail">
+  <div class="rsec" id="savedSec" style="display:none"><h4>My saved searches</h4><div class="chips" id="fSaved"></div></div>
   <div class="rsec"><h4>Source</h4><div class="chips" id="fSrc"></div></div>
   <div class="rsec"><h4>Property type</h4><div class="chips" id="fUse"></div></div>
   <div class="rsec"><h4>Transaction year</h4><div class="chips" id="fYear"></div></div>
@@ -63,6 +65,8 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
     <input class="search" id="q" placeholder="search address, company, use, year…">
     <select id="sort"></select>
     <label style="font-size:11px;color:var(--mut)">density <input type="range" id="dens" min="2" max="7" step="1"></label>
+    <button id="saveBtn" title="Save this filter set — get alerts on new matching deals" style="background:var(--card);border:1px solid var(--line);color:var(--acc);border-radius:8px;padding:7px 10px;font-size:12px;cursor:pointer">★ Save search</button>
+    <button id="watchBtn" title="Show only starred deals" style="background:var(--card);border:1px solid var(--line);color:var(--gold);border-radius:8px;padding:7px 10px;font-size:12px;cursor:pointer">☆ Watchlist</button>
     <span class="count" id="count"></span>
   </div>
   <div class="grid" id="grid"></div>
@@ -72,11 +76,14 @@ const $=s=>document.querySelector(s);
 const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;');
 const money=n=>n==null||isNaN(+n)?null:'$'+Math.round(+n).toLocaleString();
 let DATA=[],q='',sortKey='date',sortDir=-1;
+let ME=null, WATCH=new Set(), watchOnly=false;
+const dealKey=r=>String(r.ain||r.id||(r.kind+'|'+(r.name||'')+'|'+(r.date||'')));
 let dens=(function(){const v=parseInt(localStorage.getItem('dfDens'),10);return(v>=2&&v<=7)?v:4;})();
 const F={src:new Set(),use:new Set(),year:new Set(),conf:new Set(),pMin:null,uMin:null};
 const tog=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
 
 function pass(r){
+  if(watchOnly&&!WATCH.has(dealKey(r)))return false;
   if(F.src.size&&!F.src.has(r.source_label))return false;
   if(F.use.size&&!F.use.has(r.use||'—'))return false;
   if(F.year.size&&!F.year.has(String(r.year||'—')))return false;
@@ -119,7 +126,7 @@ function card(r){
   const link=r.url?`<a class="f" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">🔗 SEC filing ↗</a>`:'';
   const bigPrice=(price&&(isC||isF))?`<div class="price">${price}${isC?' <span class="mut" style="font-size:11px">est sale</span>':isF?' <span class="mut" style="font-size:11px">loan</span>':''}</div>`:'';
   return `<div class="dcard">
-    <div class="top"><div class="name">${esc(r.name)}</div>${srcBadge}</div>
+    <div class="top"><div class="name">${esc(r.name)}</div><div style="display:flex;gap:6px;align-items:center;flex-shrink:0">${srcBadge}<button class="starbtn" data-k="${esc(dealKey(r))}" title="Star / watchlist" style="background:none;border:0;font-size:15px;line-height:1;cursor:pointer;color:${WATCH.has(dealKey(r))?'var(--gold)':'var(--mut)'}">${WATCH.has(dealKey(r))?'★':'☆'}</button></div></div>
     ${bigPrice}
     <div class="dl">${details.join('')}</div>
     ${link}${conf?'<div>'+conf+'</div>':''}
@@ -150,6 +157,17 @@ $('#sort').addEventListener('change',()=>{sortKey=$('#sort').value;sortDir=(sort
 $('#dens').addEventListener('input',()=>{dens=+$('#dens').value;localStorage.setItem('dfDens',dens);render();});
 $('#sort').innerHTML=[['date','Date'],['price','Price'],['units','Units'],['year','Year'],['name','Name']].map(([k,l])=>`<option value="${k}"${k==='date'?' selected':''}>Sort: ${l}</option>`).join('');
 $('#dens').value=dens;
+// ── P1 subscription layer: accounts + saved searches + watchlist ──
+const api=(u,o)=>fetch(u,Object.assign({headers:{'Content-Type':'application/json'}},o||{})).then(r=>r.json());
+function renderAuth(){const el=$('#auth');if(ME&&ME.email){el.innerHTML=`${esc(ME.email)} · <span style="color:var(--mut)">${esc(ME.tier)}</span> · <a style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('a').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};}else{el.textContent='Sign in';el.onclick=signIn;}}
+async function loadMe(){try{ME=await api('/api/me');}catch(e){ME=null;}renderAuth();$('#savedSec').style.display=(ME&&ME.email)?'':'none';if(ME&&ME.email){loadSaved();loadWatch();}}
+async function signIn(){const email=prompt('Email for your CRCP account (magic-link sign-in):');if(!email)return;const r=await api('/auth/request',{method:'POST',body:JSON.stringify({email})});if(r.magic_link){if(confirm('Dev mode — open your magic-link to sign in now?'))location.href=r.magic_link;}else alert('Check your email for a sign-in link.');}
+async function loadSaved(){const r=await api('/api/saved-searches');const s=r.saved||[];window._saved=s;$('#fSaved').innerHTML=s.length?s.map(x=>`<span class="chip" data-sid="${x.id}" title="apply this search">${esc(x.name)}<span class="ct" data-del="${x.id}" title="delete">✕</span></span>`).join(''):'<span style="font-size:11px;color:var(--mut)">none yet — set filters, then ★ Save search</span>';}
+async function loadWatch(){const r=await api('/api/watchlist');WATCH=new Set(r.ids||[]);render();}
+$('#saveBtn').addEventListener('click',async()=>{if(!ME||!ME.email){signIn();return;}const name=prompt('Name this saved search:','My deal filter');if(name===null)return;const filters={src:[...F.src],use:[...F.use],year:[...F.year],conf:[...F.conf],pMin:F.pMin,uMin:F.uMin,q};await api('/api/saved-searches',{method:'POST',body:JSON.stringify({name,filters})});loadSaved();});
+$('#watchBtn').addEventListener('click',()=>{if(!ME||!ME.email){signIn();return;}watchOnly=!watchOnly;$('#watchBtn').style.borderColor=watchOnly?'var(--gold)':'var(--line)';render();});
+$('#fSaved').addEventListener('click',async e=>{const del=e.target.closest('[data-del]');if(del){await fetch('/api/saved-searches/'+del.dataset.del,{method:'DELETE'});loadSaved();return;}const c=e.target.closest('[data-sid]');if(!c)return;const s=(window._saved||[]).find(x=>x.id===c.dataset.sid);if(!s)return;const f=s.filters||{};F.src=new Set(f.src||[]);F.use=new Set(f.use||[]);F.year=new Set(f.year||[]);F.conf=new Set(f.conf||[]);F.pMin=f.pMin??null;F.uMin=f.uMin??null;q=f.q||'';$('#q').value=q;render();});
+$('#grid').addEventListener('click',async e=>{const b=e.target.closest('.starbtn');if(!b)return;if(!ME||!ME.email){signIn();return;}const r=await api('/api/watchlist',{method:'POST',body:JSON.stringify({id:b.dataset.k})});WATCH=new Set(r.ids||[]);render();});
 Promise.all([
   fetch('/data/deals-flow.json').then(r=>r.json()).catch(()=>({deals:[]})),
   fetch('/data/edgar-reit-deals.json').then(r=>r.json()).catch(()=>({deals:[]})),
@@ -166,6 +184,6 @@ Promise.all([
     holder:d.holder,deal_city:d.city,source_label:'HUD FHA',
     source:'HUD/FHA Multifamily Insured Mortgages (public, resellable)'}));
   DATA=c.concat(e).concat(f);
-  buildRail();render();
+  buildRail();render();loadMe();
 }).catch(()=>{$('#count').textContent='failed to load deal data';});
 </script></body></html>
diff --git a/scripts/com.steve.crcp-deal-alerts.plist b/scripts/com.steve.crcp-deal-alerts.plist
new file mode 100644
index 0000000..4dde640
--- /dev/null
+++ b/scripts/com.steve.crcp-deal-alerts.plist
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+  <key>Label</key><string>com.steve.crcp-deal-alerts</string>
+  <key>ProgramArguments</key><array>
+    <string>/opt/homebrew/bin/node</string>
+    <string>/Users/macstudio3/Projects/commercialrealestate/scripts/deal-alerts.js</string>
+  </array>
+  <key>WorkingDirectory</key><string>/Users/macstudio3/Projects/commercialrealestate</string>
+  <key>StartCalendarInterval</key><dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>15</integer></dict>
+  <key>StandardOutPath</key><string>/Users/macstudio3/Projects/commercialrealestate/tmp/deal-alerts.log</string>
+  <key>StandardErrorPath</key><string>/Users/macstudio3/Projects/commercialrealestate/tmp/deal-alerts.log</string>
+</dict></plist>
diff --git a/scripts/crcp-accounts.js b/scripts/crcp-accounts.js
new file mode 100644
index 0000000..26e7580
--- /dev/null
+++ b/scripts/crcp-accounts.js
@@ -0,0 +1,92 @@
+// crcp-accounts.js — P1 subscription-layer for the CRCP deal-flow tool (docs/TOOL-SPEC.md).
+// Per-user accounts (magic-link) + saved searches + watchlist, layered ON TOP of the existing
+// whole-site basic-auth. Durable local JSON store (atomic write, same pattern as agent-contacts).
+// $0, local, reversible. Mount from serve.js:  require('./crcp-accounts')(app, ROOT);
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+module.exports = function mountAccounts(app, ROOT) {
+  const FILE = path.join(ROOT, 'data', 'crcp-accounts.json');
+  const load = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return {}; } };
+  const save = (db) => { const t = FILE + '.tmp'; fs.writeFileSync(t, JSON.stringify(db, null, 2)); fs.renameSync(t, FILE); };
+  const blank = () => ({ users: {}, tokens: {}, sessions: {}, saved: {}, watch: {} });
+  const norm = e => String(e || '').trim().toLowerCase();
+  const rid = () => crypto.randomBytes(24).toString('hex');
+  const now = () => Date.now();
+  const cookies = req => { const o = {}; (req.headers.cookie || '').split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) o[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); return o; };
+
+  function userOf(req) {
+    const db = load(); const sid = cookies(req).crcp_sid;
+    const s = sid && db.sessions[sid];
+    if (!s || s.exp < now()) return null;
+    return { email: s.email, tier: (db.users[s.email] || {}).tier || 'free' };
+  }
+
+  // ── magic-link auth ──────────────────────────────────────────────────────────
+  // POST /auth/request {email} -> create a 15-min token + link. Emails it when a mailer is wired
+  // (CRCP_MAIL=1 via George), else returns the link so login works in dev. Login email to one's OWN
+  // address is transactional, not a send-to-list.
+  app.post('/auth/request', (req, res) => {
+    const email = norm(req.body && req.body.email);
+    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.status(400).json({ error: 'valid email required' });
+    const db = load(); ['users', 'tokens', 'sessions', 'saved', 'watch'].forEach(k => db[k] = db[k] || blank()[k]);
+    if (!db.users[email]) db.users[email] = { tier: 'free', created: new Date().toISOString() };
+    const tok = rid(); db.tokens[tok] = { email, exp: now() + 15 * 60 * 1000 }; save(db);
+    const base = process.env.CRCP_BASE || `${req.protocol}://${req.get('host')}`;
+    const link = `${base}/auth/verify?token=${tok}`;
+    let emailed = false;
+    if (process.env.CRCP_MAIL === '1') {
+      try { require('child_process').execFile('node', [path.join(__dirname, 'send-magic-link.js'), email, link], () => {}); emailed = true; } catch (_) {}
+    }
+    res.json({ ok: true, emailed, magic_link: emailed ? undefined : link });   // link returned only in dev
+  });
+
+  app.get('/auth/verify', (req, res) => {
+    const db = load(); const t = db.tokens && db.tokens[req.query.token];
+    if (!t || t.exp < now()) return res.status(400).send('Link expired or invalid — request a new one.');
+    delete db.tokens[req.query.token];
+    const sid = rid(); db.sessions[sid] = { email: t.email, exp: now() + 30 * 86400000 }; save(db);
+    res.set('Set-Cookie', `crcp_sid=${sid}; Path=/; Max-Age=${30 * 86400}; HttpOnly; SameSite=Lax`);
+    res.redirect('/deals-flow.html');
+  });
+  app.post('/auth/logout', (req, res) => {
+    const db = load(); const sid = cookies(req).crcp_sid; if (sid && db.sessions[sid]) { delete db.sessions[sid]; save(db); }
+    res.set('Set-Cookie', 'crcp_sid=; Path=/; Max-Age=0').json({ ok: true });
+  });
+  app.get('/api/me', (req, res) => res.json(userOf(req) || { email: null }));
+
+  // ── saved searches ───────────────────────────────────────────────────────────
+  app.get('/api/saved-searches', (req, res) => {
+    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
+    res.json({ saved: (load().saved[u.email]) || [] });
+  });
+  app.post('/api/saved-searches', (req, res) => {
+    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
+    const { name, filters } = req.body || {};
+    if (!filters || typeof filters !== 'object') return res.status(400).json({ error: 'filters required' });
+    const db = load(); db.saved[u.email] = db.saved[u.email] || [];
+    const rec = { id: rid().slice(0, 12), name: String(name || 'Saved search').slice(0, 80), filters, created: new Date().toISOString(), last_seen: new Date().toISOString().slice(0, 10) };
+    db.saved[u.email].unshift(rec); save(db);
+    res.json({ ok: true, search: rec });
+  });
+  app.delete('/api/saved-searches/:id', (req, res) => {
+    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
+    const db = load(); db.saved[u.email] = (db.saved[u.email] || []).filter(s => s.id !== req.params.id); save(db);
+    res.json({ ok: true });
+  });
+
+  // ── watchlist (star a deal) ────────────────────────────────────────────────────
+  app.get('/api/watchlist', (req, res) => { const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' }); res.json({ ids: (load().watch[u.email]) || [] }); });
+  app.post('/api/watchlist', (req, res) => {
+    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
+    const id = String((req.body || {}).id || '').slice(0, 64); if (!id) return res.status(400).json({ error: 'id required' });
+    const db = load(); const set = new Set(db.watch[u.email] || []);
+    set.has(id) ? set.delete(id) : set.add(id); db.watch[u.email] = [...set]; save(db);
+    res.json({ ok: true, ids: db.watch[u.email] });
+  });
+
+  module.exports.userOf = userOf;   // expose for the alert job
+  console.log('[crcp-accounts] P1 subscription layer mounted (accounts + saved-searches + watchlist)');
+};
diff --git a/scripts/deal-alerts.js b/scripts/deal-alerts.js
new file mode 100644
index 0000000..5d219ad
--- /dev/null
+++ b/scripts/deal-alerts.js
@@ -0,0 +1,54 @@
+// deal-alerts.js — P1 retention hook (docs/TOOL-SPEC.md): for each user's saved searches, find NEW
+// deals (added since the search's last_seen) that match its filters, and send a digest. $0, local.
+// The digest is the reason a loan officer opens the tool daily. Email send is best-effort via a mailer
+// (send-magic-link.js pattern / George) when CRCP_MAIL=1; otherwise writes the digest to
+// data/deal-alerts-out/<email>-<date>.txt so nothing is lost and no send fires unbidden.
+//   node scripts/deal-alerts.js
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const ROOT = path.join(__dirname, '..');
+const ACC = path.join(ROOT, 'data', 'crcp-accounts.json');
+const FLOW = path.join(ROOT, 'data', 'deals-flow.json');
+const OUTDIR = path.join(ROOT, 'data', 'deal-alerts-out');
+const load = f => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
+const today = new Date().toISOString().slice(0, 10);
+
+function matches(d, f) {
+  if (f.src && f.src.length && !f.src.includes('LA County Assessor')) return false; // county-only feed here
+  if (f.use && f.use.length && !f.use.includes(d.use)) return false;
+  if (f.pMin != null && !(+d.price >= f.pMin)) return false;
+  if (f.uMin != null && !(+d.units >= f.uMin)) return false;
+  if (f.q) { const t = String(f.q).toLowerCase(); if (![d.address, d.use, d.year].some(v => v != null && String(v).toLowerCase().includes(t))) return false; }
+  return true;
+}
+
+(function main() {
+  const db = load(ACC); const flow = load(FLOW);
+  if (!db || !db.saved || !flow) { console.log('no accounts/saved or no deal data — nothing to do.'); return; }
+  const deals = flow.deals || [];
+  fs.mkdirSync(OUTDIR, { recursive: true });
+  let digests = 0, changed = false;
+  for (const [email, searches] of Object.entries(db.saved)) {
+    const blocks = [];
+    for (const s of searches) {
+      const since = s.last_seen || '2000-01-01';
+      const hits = deals.filter(d => (d.date || '') > since && matches(d, s.filters || {}));
+      if (hits.length) {
+        hits.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
+        blocks.push(`● "${s.name}" — ${hits.length} new since ${since}\n` +
+          hits.slice(0, 15).map(d => `   ${d.date}  $${(+d.price || 0).toLocaleString().padStart(12)}  ${(d.use || '').padEnd(12)} ${d.address || d.ain}`).join('\n'));
+      }
+      s.last_seen = today; changed = true;   // advance the watermark
+    }
+    if (blocks.length) {
+      const body = `CRCP Deal Flow — new matches for your saved searches (${today})\n\n${blocks.join('\n\n')}\n\nView: ${process.env.CRCP_BASE || 'https://crcp.agentabrams.com'}/deals-flow.html`;
+      const out = path.join(OUTDIR, `${email.replace(/[^a-z0-9]/gi, '_')}-${today}.txt`);
+      fs.writeFileSync(out, body);
+      if (process.env.CRCP_MAIL === '1') { try { require('child_process').execFile('node', [path.join(__dirname, 'send-magic-link.js'), email, body], () => {}); } catch (_) {} }
+      digests++;
+    }
+  }
+  if (changed) { const t = ACC + '.tmp'; fs.writeFileSync(t, JSON.stringify(db, null, 2)); fs.renameSync(t, ACC); }
+  console.log(`deal-alerts: ${digests} digest(s) generated (${process.env.CRCP_MAIL === '1' ? 'emailed + ' : ''}written to data/deal-alerts-out/). $0 local.`);
+})();

← 76508c9 crcp /api/firm: dedupe current/closed/expired by address+cit  ·  back to Commercialrealestate  ·  deploy P1 + wire email: send-magic-link.js (George, fail-clo 25ec39f →