← 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
M .gitignoreM public/deals-flow.htmlA scripts/com.steve.crcp-deal-alerts.plistA scripts/crcp-accounts.jsA scripts/deal-alerts.js
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,'&').replace(/</g,'<');
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 →