← back to Sublease Agentabrams
Claim-your-profile: brokers claim their page + submit info (POST /api/brokers/:id/claim, append-only file, DB-free/snapshot-safe); pending-review admin queue at /claims; claims file protected from rsync --delete
e16ef948eae8cfd341b5b3afa56928a351cb195c · 2026-07-20 14:42:10 -0700 · Steve
Files touched
M .deploy.confM .gitignoreM public/admin.htmlM public/broker-profile.htmlA public/claims.htmlM server.js
Diff
commit e16ef948eae8cfd341b5b3afa56928a351cb195c
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 20 14:42:10 2026 -0700
Claim-your-profile: brokers claim their page + submit info (POST /api/brokers/:id/claim, append-only file, DB-free/snapshot-safe); pending-review admin queue at /claims; claims file protected from rsync --delete
---
.deploy.conf | 2 +-
.gitignore | 1 +
public/admin.html | 2 +-
public/broker-profile.html | 46 +++++++++++++++++++++++++++++++++++++++++++++-
public/claims.html | 41 +++++++++++++++++++++++++++++++++++++++++
server.js | 33 +++++++++++++++++++++++++++++++++
6 files changed, 122 insertions(+), 3 deletions(-)
diff --git a/.deploy.conf b/.deploy.conf
index 4db393a..266234d 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -7,4 +7,4 @@ HEALTH_URL=http://localhost:9714/api/health
INSTALL_CMD="npm ci --omit=dev"
BUILD_CMD=""
REQUIRED_ENVS="USE_SNAPSHOT" # prod runs USE_SNAPSHOT=1 (serves data/snapshot.json)
-RSYNC_EXTRA_EXCLUDES="tmp-server.log data/*.db"
+RSYNC_EXTRA_EXCLUDES="tmp-server.log data/*.db data/claims.jsonl"
diff --git a/.gitignore b/.gitignore
index 0af53ff..cd6627d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ data/*.db
tmp-*.png
tmp-shot.js
tmp-server.log
+data/claims.jsonl
diff --git a/public/admin.html b/public/admin.html
index 4f06f37..50c89f9 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -21,7 +21,7 @@
th{color:var(--mut);text-transform:uppercase;font-size:11px;letter-spacing:.06em}
</style></head><body><div class="wrap">
<h1>CRUnifiedDB — Admin</h1>
-<p class="sub"><a href="/">← marketplace</a> · per-firm crawler agents write here. Run one with <code>node crawl/run.js <slug></code>.</p>
+<p class="sub"><a href="/">← marketplace</a> · <a href="/claims">profile claims →</a> · per-firm crawler agents write here. Run one with <code>node crawl/run.js <slug></code>.</p>
<h2>Sponsor firms</h2>
<div class="cards" id="sponsors"></div>
diff --git a/public/broker-profile.html b/public/broker-profile.html
index 4c04434..8a909e1 100644
--- a/public/broker-profile.html
+++ b/public/broker-profile.html
@@ -21,8 +21,36 @@
<header class="top"><div class="wrap"><a href="/#brokers">← Broker directory</a></div></header>
<div class="head"><div class="wrap" id="head"></div></div>
<div class="wrap"><div class="card" id="body"></div>
- <p class="muted" style="font-size:12.5px">Broker contact data aggregated from public commercial-real-estate sources (CRUnifiedDB). Verify before transacting.</p>
+
+ <div class="card" id="claimBlock">
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
+ <div><b>Is this you?</b> <span class="muted">Claim this profile to keep your name, contact and info accurate.</span></div>
+ <button class="cta" id="claimBtn" style="border:0;cursor:pointer">Claim this profile</button>
+ </div>
+ <form id="claimForm" style="display:none;margin-top:16px">
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
+ <label>Your name*<input name="name" required></label>
+ <label>Email*<input name="email" type="email" required></label>
+ <label>Phone<input name="phone"></label>
+ <label>Title<input name="title" placeholder="e.g. Senior Vice President"></label>
+ <label>Firm<input name="firm"></label>
+ <label>Website<input name="website" placeholder="https://"></label>
+ <label style="grid-column:1/3">LinkedIn<input name="linkedin" placeholder="https://linkedin.com/in/…"></label>
+ <label style="grid-column:1/3">Bio<textarea name="bio" rows="3" placeholder="Short professional bio"></textarea></label>
+ <label style="grid-column:1/3">Corrections / notes<textarea name="corrections" rows="2" placeholder="Anything on this profile that's wrong?"></textarea></label>
+ <label style="grid-column:1/3;display:flex;gap:8px;align-items:center;font-size:13.5px"><input type="checkbox" name="is_self" style="width:auto"> I confirm I am this person or an authorized representative.</label>
+ </div>
+ <button class="cta" type="submit" style="border:0;cursor:pointer;margin-top:12px">Submit claim for review</button>
+ <div id="claimMsg" class="muted" style="margin-top:10px;font-size:13px"></div>
+ </form>
+ </div>
+
+ <p class="muted" style="font-size:12.5px">Broker contact data aggregated from public commercial-real-estate sources (CRUnifiedDB). Claims are reviewed before any profile change is published. Verify before transacting.</p>
</div>
+<style>
+ #claimForm label{display:flex;flex-direction:column;font-size:12.5px;color:var(--mut);gap:4px}
+ #claimForm input,#claimForm textarea{padding:8px 10px;border:1px solid var(--line);border-radius:8px;font:inherit;color:var(--ink)}
+</style>
<script>
const id=location.pathname.split('/').pop();
(async()=>{
@@ -43,4 +71,20 @@ const id=location.pathname.split('/').pop();
row('Specialties',b.specialties)+
(b.email?`<a class="cta" href="mailto:${b.email}">Contact ${b.name.split(' ')[0]}</a>`:'');
})();
+document.getElementById('claimBtn').addEventListener('click',()=>{
+ const f=document.getElementById('claimForm'); f.style.display=f.style.display==='none'?'block':'none';
+});
+document.getElementById('claimForm').addEventListener('submit',async(e)=>{
+ e.preventDefault();
+ const fd=Object.fromEntries(new FormData(e.target).entries());
+ fd.is_self=e.target.is_self.checked;
+ const msg=document.getElementById('claimMsg');
+ msg.textContent='Submitting…';
+ try{
+ const r=await fetch('/api/brokers/'+id+'/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(fd)});
+ const j=await r.json();
+ if(j.ok){ e.target.reset(); msg.style.color='#1a7a44'; msg.textContent='✓ Claim submitted — our team will review it before any change goes live.'; }
+ else { msg.style.color='#b3261e'; msg.textContent='✗ '+(j.error||'Could not submit'); }
+ }catch(err){ msg.style.color='#b3261e'; msg.textContent='✗ Network error — try again.'; }
+});
</script></body></html>
diff --git a/public/claims.html b/public/claims.html
new file mode 100644
index 0000000..0ad02dd
--- /dev/null
+++ b/public/claims.html
@@ -0,0 +1,41 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Profile claims · Admin</title>
+<style>
+ :root{--ink:#14181d;--mut:#6b7480;--line:#e7e9ee;--bg:#fbfbfc;--accent:#1a5f3c}
+ body{margin:0;font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;color:var(--ink);background:var(--bg)}
+ .wrap{max-width:960px;margin:0 auto;padding:24px 22px}a{color:var(--accent)}
+ h1{font-size:20px;margin:0 0 4px}.sub{color:var(--mut);margin:0 0 20px}
+ .claim{border:1px solid var(--line);border-radius:12px;background:#fff;padding:16px;margin-bottom:12px}
+ .claim .h{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:baseline}
+ .claim .nm{font-weight:700}.claim .when{color:var(--mut);font-size:12px}
+ .pend{font-size:11px;padding:2px 8px;border-radius:20px;background:#fff4e5;color:#b6791f;text-transform:uppercase;letter-spacing:.05em}
+ .grid{display:grid;grid-template-columns:120px 1fr;gap:4px 12px;margin-top:10px;font-size:13.5px}
+ .grid .k{color:var(--mut)}.self{color:#1a7a44;font-weight:600}
+ .empty{color:var(--mut);border:1px dashed var(--line);border-radius:12px;padding:30px;text-align:center}
+</style></head><body><div class="wrap">
+<h1>Profile claims</h1>
+<p class="sub"><a href="/">← marketplace</a> · brokers who claimed their profile. Pending review — approving a claim (merging into the profile) is a manual admin step; nothing here has changed a public profile.</p>
+<div id="list"></div>
+</div>
+<script>
+const fmt=t=>t?new Date(t).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
+(async()=>{
+ const d=await fetch('/api/claims').then(r=>r.json()).catch(()=>({claims:[]}));
+ const L=document.getElementById('list');
+ if(!d.claims||!d.claims.length){L.innerHTML='<div class="empty">No profile claims submitted yet.</div>';return;}
+ const row=(k,v)=>v?`<div class="k">${k}</div><div>${v}</div>`:'';
+ L.innerHTML=d.claims.map(c=>`<div class="claim">
+ <div class="h"><div><span class="nm">${c.name}</span> ${c.is_self?'<span class="self">✓ self-confirmed</span>':''}
+ — claims broker <a href="/brokers/${c.broker_id}" target="_blank">#${c.broker_id}</a></div>
+ <div><span class="pend">${c.status||'pending'}</span> <span class="when">🕓 ${fmt(c.ts)}</span></div></div>
+ <div class="grid">
+ ${row('Email',c.email?`<a href="mailto:${c.email}">${c.email}</a>`:'')}
+ ${row('Phone',c.phone)}${row('Title',c.title)}${row('Firm',c.firm)}
+ ${row('Website',c.website?`<a href="${c.website}" target="_blank" rel="noopener">${c.website}</a>`:'')}
+ ${row('LinkedIn',c.linkedin?`<a href="${c.linkedin}" target="_blank" rel="noopener">${c.linkedin}</a>`:'')}
+ ${row('Bio',c.bio)}${row('Corrections',c.corrections)}
+ </div></div>`).join('');
+})();
+</script></body></html>
diff --git a/server.js b/server.js
index bec1342..439dd3f 100644
--- a/server.js
+++ b/server.js
@@ -24,6 +24,9 @@ else {
q = (sql, params) => pool.query(sql, params).then(r => r.rows);
}
+app.use(express.json({ limit: '64kb' }));
+const CLAIMS_FILE = path.join(__dirname, 'data', 'claims.jsonl');
+
const CREDS = ['admin:DW2024!', 'Boomer:Sublease2024!']
.concat((process.env.BASIC_AUTH_EXTRA || '').split(',').map(s => s.trim()).filter(Boolean));
const ACCEPTED = new Set(CREDS.map(c => 'Basic ' + Buffer.from(c).toString('base64')));
@@ -141,6 +144,35 @@ app.get('/api/brokers/:id', async (req, res) => {
} catch (e) { res.status(500).json({ error: String(e) }); }
});
+// Claim a broker profile — writes an append-only pending claim (no DB needed; works in snapshot mode).
+// Claims are NEVER auto-applied to the public profile; an admin reviews them at /claims.
+const clean = (s, n = 500) => String(s == null ? '' : s).slice(0, n);
+app.post('/api/brokers/:id/claim', (req, res) => {
+ try {
+ const id = +req.params.id; const b = req.body || {};
+ if (!b.name || !b.email) return res.status(400).json({ error: 'name and email are required' });
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(b.email)) return res.status(400).json({ error: 'valid email required' });
+ const rec = {
+ ts: new Date().toISOString(), broker_id: id, status: 'pending',
+ name: clean(b.name, 120), email: clean(b.email, 160), phone: clean(b.phone, 40),
+ title: clean(b.title, 120), firm: clean(b.firm, 160), website: clean(b.website, 200),
+ linkedin: clean(b.linkedin, 200), bio: clean(b.bio, 2000), corrections: clean(b.corrections, 2000),
+ is_self: !!b.is_self, ua: clean(req.headers['user-agent'], 200),
+ };
+ fs.appendFileSync(CLAIMS_FILE, JSON.stringify(rec) + '\n');
+ res.json({ ok: true, message: 'Claim submitted for review.' });
+ } catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
+// Admin: list submitted claims (newest first)
+app.get('/api/claims', (_q, res) => {
+ try {
+ const raw = fs.existsSync(CLAIMS_FILE) ? fs.readFileSync(CLAIMS_FILE, 'utf8') : '';
+ const claims = raw.split('\n').filter(Boolean).map((l, i) => { try { return { i, ...JSON.parse(l) }; } catch { return null; } }).filter(Boolean).reverse();
+ res.json({ claims, count: claims.length });
+ } catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
app.get('/api/stats', async (_q, res) => {
try {
if (USE_SNAPSHOT) return res.json({ ...snap.stats, recent_runs: snap.recent_runs });
@@ -158,5 +190,6 @@ app.get('/api/stats', async (_q, res) => {
app.get('/broker/:slug', (_q, r) => r.sendFile(path.join(PUB, 'broker.html')));
app.get('/brokers/:id', (_q, r) => r.sendFile(path.join(PUB, 'broker-profile.html')));
+app.get('/claims', (_q, r) => r.sendFile(path.join(PUB, 'claims.html')));
app.use(express.static(PUB, { extensions: ['html'] }));
app.listen(PORT, () => console.log(`sublease-agentabrams on ${PORT} (${USE_SNAPSHOT ? 'snapshot' : 'CRUnifiedDB'})`));
← 1d15a50 auto-save: 2026-07-20T14:02:31 (1 files) — tmp-shot-local.js
·
back to Sublease Agentabrams
·
Refresh snapshot: 2,292 mapped (72.6%, +956 via Nominatim) + 4cb548c →