← back to Rentv
PR CRM: user-authored notes on any entity (org/broker/person/property/deal)
6b792cc906b48f8ef0a191a71cdf96ac0205d3fa · 2026-08-06 10:26:42 -0700 · Steve Abrams
- migration 008: pr_notes table (tenant_id, entity_type, entity_id, body, author,
pinned, created/updated) + RLS tenant policy + pr_app grants
- notes service: list/add/update/pin/remove, author-stamped, records a
note_added activity into the entity timeline; RLS scopes reads, insert sets
tenant_id to satisfy WITH CHECK
- 5 routes under /api/pr/notes (read for GET, write for mutations), mounted
AFTER the router gate so db.tenantMiddleware binds them (RLS auto-scoped)
- reusable PR.notes() widget in pr-shared.js (compose+list, pin/edit/delete,
RBAC-aware via memoized /auth/me) wired into the person + org drawers
- verified: RLS isolation (tenant 2 sees 0 of tenant 1's notes) + RBAC
(read-only user 403) end-to-end over HTTP
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/admin/pr-intelligence/organizations.htmlM public/admin/pr-intelligence/people.htmlM public/admin/pr-intelligence/pr-shared.jsM src/pr/index.jsA src/pr/services/notes.js
Diff
commit 6b792cc906b48f8ef0a191a71cdf96ac0205d3fa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 6 10:26:42 2026 -0700
PR CRM: user-authored notes on any entity (org/broker/person/property/deal)
- migration 008: pr_notes table (tenant_id, entity_type, entity_id, body, author,
pinned, created/updated) + RLS tenant policy + pr_app grants
- notes service: list/add/update/pin/remove, author-stamped, records a
note_added activity into the entity timeline; RLS scopes reads, insert sets
tenant_id to satisfy WITH CHECK
- 5 routes under /api/pr/notes (read for GET, write for mutations), mounted
AFTER the router gate so db.tenantMiddleware binds them (RLS auto-scoped)
- reusable PR.notes() widget in pr-shared.js (compose+list, pin/edit/delete,
RBAC-aware via memoized /auth/me) wired into the person + org drawers
- verified: RLS isolation (tenant 2 sees 0 of tenant 1's notes) + RBAC
(read-only user 403) end-to-end over HTTP
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/admin/pr-intelligence/organizations.html | 3 +
public/admin/pr-intelligence/people.html | 3 +
public/admin/pr-intelligence/pr-shared.js | 84 +++++++++++++++++++++++++
src/pr/index.js | 24 +++++++
src/pr/services/notes.js | 55 ++++++++++++++++
5 files changed, 169 insertions(+)
diff --git a/public/admin/pr-intelligence/organizations.html b/public/admin/pr-intelligence/organizations.html
index a0e0a5eb..a6bd833b 100644
--- a/public/admin/pr-intelligence/organizations.html
+++ b/public/admin/pr-intelligence/organizations.html
@@ -431,10 +431,13 @@
<h3 style="margin:14px 0 8px">Outreach history</h3>
${o.outreach.map((m) => `<div class="gatechk">${PR.statusBadge(m.status)} <span>${esc(m.subject || '(no subject)')}</span>
<span style="color:var(--muted);margin-left:auto">${PR.dateShort(m.sent_at || m.drafted_at)}</span></div>`).join('') || '<div class="empty">No outreach yet</div>'}
+ <h3 style="margin:14px 0 8px">Notes <span style="font-weight:400;color:var(--muted);font-size:12px">(team notes — timestamped & attributed)</span></h3>
+ <div id="o-notethread"></div>
<h3 style="margin:14px 0 8px">Recent activity</h3>
${o.recent_activity.slice(0, 12).map((a) => `<div class="gatechk"><span style="color:var(--muted)">${PR.dateShort(a.created_at)}</span>
<span>${esc(a.activity)}</span><span style="color:var(--muted)">${esc(a.actor || '')}</span></div>`).join('')}`);
+ PR.notes(body.querySelector('#o-notethread'), 'organization', o.id);
body.querySelector('#d-verify').addEventListener('click', () =>
PR.guard(() => PR.api('/jobs', { method: 'POST', body: { job_type: 'verify-organization', payload: { organization_id: o.id } } }), 'Verify job queued'));
body.querySelector('#d-people').addEventListener('click', () =>
diff --git a/public/admin/pr-intelligence/people.html b/public/admin/pr-intelligence/people.html
index e81433e7..72e5be00 100644
--- a/public/admin/pr-intelligence/people.html
+++ b/public/admin/pr-intelligence/people.html
@@ -120,6 +120,8 @@
<span style="color:var(--muted);margin-left:auto">${PR.dateShort(m.sent_at || m.drafted_at)}</span></div>`).join('') || '<div class="empty">No outreach yet</div>'}
<h3 style="margin:14px 0 8px">Correspondence <span style="font-weight:400;color:var(--muted);font-size:12px">(email dates & times, synced from Gmail)</span></h3>
<div id="e-timeline"><div class="empty">Loading…</div></div>
+ <h3 style="margin:14px 0 8px">Notes <span style="font-weight:400;color:var(--muted);font-size:12px">(team notes — timestamped & attributed)</span></h3>
+ <div id="e-notethread"></div>
<h3 style="margin:14px 0 8px">Audit trail</h3>
${p.recent_activity.slice(0, 12).map((a) => `<div class="gatechk"><span style="color:var(--muted)">${PR.dateShort(a.created_at)}</span>
<span>${PR.esc(a.activity)}</span><span style="color:var(--muted)">${PR.esc(a.actor || '')}</span></div>`).join('')}`);
@@ -139,6 +141,7 @@
el.innerHTML = rows.map((m) => `<div class="gatechk">${arrow(m.direction)} <span>${PR.esc(m.subject || '(no subject)')}</span>
<span style="color:var(--muted);margin-left:auto" title="${PR.esc(m.corresponded_at || '')}">🕓 ${PR.esc(when(m))}</span></div>`).join('');
}).catch(() => { const el = body.querySelector('#e-timeline'); if (el) el.innerHTML = '<div class="empty">Timeline unavailable.</div>'; });
+ PR.notes(body.querySelector('#e-notethread'), 'person', id);
body.querySelector('#e-save').addEventListener('click', async () => {
await PR.guard(() => PR.api('/people/' + id, { method: 'PUT', body: {
exact_title: body.querySelector('#e-title').value,
diff --git a/public/admin/pr-intelligence/pr-shared.js b/public/admin/pr-intelligence/pr-shared.js
index 6efcd9d0..bc375db1 100644
--- a/public/admin/pr-intelligence/pr-shared.js
+++ b/public/admin/pr-intelligence/pr-shared.js
@@ -288,6 +288,90 @@
return el;
},
+ // ── Current user (memoized) + client-side capability check ─────────────────
+ // Server-side RBAC (requireCap + the router gate) is the real enforcement; this is
+ // only for hiding controls the caller can't use, to avoid a guaranteed 403.
+ _me: null,
+ async me() { if (PR._me === null) { try { PR._me = await PR.api('/auth/me'); } catch { PR._me = {}; } } return PR._me; },
+ can(cap) {
+ const role = PR._me && PR._me.user && PR._me.user.role;
+ const caps = { admin: ['read', 'write', 'outreach', 'export', 'settings', 'integrations', 'users', 'tenants'],
+ developer: ['read', 'write', 'outreach', 'export', 'settings', 'integrations'],
+ pro: ['read', 'write', 'outreach', 'export'], user: ['read'] };
+ return !!(role && caps[role] && caps[role].includes(cap));
+ },
+
+ // ── Reusable NOTES widget ──────────────────────────────────────────────────
+ // Mounts a full notes thread (compose + list, author + timestamp, pin/edit/delete)
+ // into `el` for ANY entity: PR.notes(el, 'organization'|'person'|'property'|'deal'|…, id).
+ // One implementation, used by the org drawer, person drawer, deal desk, etc.
+ async notes(el, entityType, entityId) {
+ if (!el) return;
+ await PR.me();
+ const canWrite = PR.can('write');
+ el.innerHTML = `
+ <div class="pr-notes">
+ ${canWrite ? `<div class="pr-note-compose" style="display:flex;gap:6px;margin-bottom:10px">
+ <textarea class="pr-note-input" rows="2" placeholder="Add a note… (⌘/Ctrl+Enter to save)"
+ style="flex:1;padding:6px 8px;border:1px solid var(--line);border-radius:6px;font:12.5px inherit;resize:vertical"></textarea>
+ <button class="btn sm pr-note-add" style="align-self:flex-start">Add note</button>
+ </div>` : ''}
+ <div class="pr-note-list"><div class="empty">Loading…</div></div>
+ </div>`;
+ const listEl = el.querySelector('.pr-note-list');
+ const render = (n) => {
+ const acts = canWrite
+ ? `<span class="pr-note-actions" style="margin-left:8px">
+ <a href="#" data-act="pin">${n.pinned ? 'Unpin' : 'Pin'}</a> ·
+ <a href="#" data-act="edit">Edit</a> ·
+ <a href="#" data-act="del">Delete</a></span>` : '';
+ return `<div class="pr-note" data-id="${n.id}" style="border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:8px;${n.pinned ? 'background:#fffbea' : ''}">
+ <div class="pr-note-body" style="white-space:pre-wrap;font-size:13px">${PR.esc(n.body)}</div>
+ <div class="pr-note-meta" style="color:var(--muted);font-size:11.5px;margin-top:6px" title="${PR.esc(n.created_at || '')}">
+ ${n.pinned ? '📌 ' : ''}${PR.esc(n.author_name || '—')} · 🕓 ${PR.date(n.created_at)}${n.updated_at && n.updated_at !== n.created_at ? ' · edited ' + PR.date(n.updated_at) : ''}${acts}
+ </div>
+ </div>`;
+ };
+ async function refresh() {
+ try {
+ const { rows } = await PR.api('/notes/' + encodeURIComponent(entityType) + '/' + encodeURIComponent(entityId));
+ listEl.innerHTML = rows.length ? rows.map(render).join('') : '<div class="empty">No notes yet.</div>';
+ if (canWrite) wire(rows);
+ } catch (e) { listEl.innerHTML = `<div class="empty">Notes unavailable (${PR.esc(e.message)})</div>`; }
+ }
+ function wire(rows) {
+ listEl.querySelectorAll('.pr-note').forEach((row) => {
+ const id = row.dataset.id;
+ const n = rows.find((r) => String(r.id) === String(id));
+ row.querySelector('[data-act=pin]').onclick = async (e) => { e.preventDefault(); await PR.guard(() => PR.api('/notes/' + id + '/pin', { method: 'POST' })); refresh(); };
+ row.querySelector('[data-act=del]').onclick = async (e) => { e.preventDefault(); if (!confirm('Delete this note?')) return; await PR.guard(() => PR.api('/notes/' + id, { method: 'DELETE' }), 'Note deleted'); refresh(); };
+ row.querySelector('[data-act=edit]').onclick = (e) => {
+ e.preventDefault();
+ const bodyEl = row.querySelector('.pr-note-body');
+ bodyEl.innerHTML = `<textarea class="pr-note-edit" rows="3" style="width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:6px;font:12.5px inherit">${PR.esc(n.body)}</textarea>
+ <div style="margin-top:4px"><button class="btn sm pr-note-save">Save</button> <button class="btn sm ghost pr-note-cancel">Cancel</button></div>`;
+ bodyEl.querySelector('.pr-note-cancel').onclick = () => refresh();
+ bodyEl.querySelector('.pr-note-save').onclick = async () => {
+ const body = bodyEl.querySelector('.pr-note-edit').value.trim(); if (!body) return;
+ await PR.guard(() => PR.api('/notes/' + id, { method: 'PUT', body: { body } }), 'Note updated'); refresh();
+ };
+ };
+ });
+ }
+ if (canWrite) {
+ const input = el.querySelector('.pr-note-input');
+ const addBtn = el.querySelector('.pr-note-add');
+ addBtn.onclick = async () => {
+ const body = input.value.trim(); if (!body) return;
+ addBtn.disabled = true;
+ try { await PR.api('/notes', { method: 'POST', body: { entity_type: entityType, entity_id: entityId, body } }); input.value = ''; PR.toast('Note added'); refresh(); }
+ catch (e) { PR.toast(e.message, true); } finally { addBtn.disabled = false; }
+ };
+ input.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') addBtn.click(); });
+ }
+ refresh();
+ },
+
// Evidence list HTML (used by org/person drawers + review center pane)
evidenceHtml(evidence, opts = {}) {
if (!evidence || !evidence.length) return '<div class="empty">No source evidence recorded</div>';
diff --git a/src/pr/index.js b/src/pr/index.js
index d2e42d94..0b84f5f3 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -196,6 +196,30 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
// Everything registered BELOW this line is behind the router capability gate.
prRouterGate(app);
+ // ── Notes: user-authored, timestamped notes on any entity (org / broker / person / property / deal) ──
+ // Registered AFTER the gate so db.tenantMiddleware is active → RLS auto-scopes every notes query to
+ // the caller's tenant (no manual WHERE tenant_id needed).
+ const notes = require('./services/notes');
+ app.get('/api/pr/notes/:entityType/:entityId', adminOnly, requireCap('read'), h(async (req, res) => {
+ res.json({ rows: await notes.list({ entity_type: req.params.entityType, entity_id: Number(req.params.entityId) }) });
+ }));
+ app.post('/api/pr/notes', adminOnly, requireCap('write'), h(async (req, res) => {
+ const b = req.body || {};
+ const row = await notes.add(
+ { tenant_id: req.prAuth.tenant.id, entity_type: b.entity_type, entity_id: Number(b.entity_id), body: b.body },
+ req.prAuth.user);
+ res.json(row);
+ }));
+ app.put('/api/pr/notes/:id', adminOnly, requireCap('write'), h(async (req, res) => {
+ res.json(await notes.update(Number(req.params.id), (req.body || {}).body, req.prAuth.user));
+ }));
+ app.post('/api/pr/notes/:id/pin', adminOnly, requireCap('write'), h(async (req, res) => {
+ res.json(await notes.togglePin(Number(req.params.id)));
+ }));
+ app.delete('/api/pr/notes/:id', adminOnly, requireCap('write'), h(async (req, res) => {
+ res.json(await notes.remove(Number(req.params.id), req.prAuth.user));
+ }));
+
// ── Health & meta ──────────────────────────────────────────────────────────
app.get('/api/pr/health', adminOnly, async (_q, res) => res.json(await db.health()));
app.get('/api/pr/meta', adminOnly, h(async (_q, res) => {
diff --git a/src/pr/services/notes.js b/src/pr/services/notes.js
new file mode 100644
index 00000000..6453d02c
--- /dev/null
+++ b/src/pr/services/notes.js
@@ -0,0 +1,55 @@
+'use strict';
+// User notes on any entity (organization / person / property / broker / deal / …).
+// Tenant isolation is enforced by RLS (the request runs in db.tenantMiddleware); we still
+// pass tenant_id on INSERT so the WITH CHECK policy accepts the row under the caller's tenant.
+const db = require('../db');
+const audit = require('./audit');
+
+const ENTITY_TYPES = new Set(['organization', 'person', 'property', 'broker', 'deal', 'campaign', 'tenant']);
+
+async function list({ entity_type, entity_id }) {
+ return db.rows(
+ `SELECT id, entity_type, entity_id, body, author_user_id, author_name, pinned, created_at, updated_at
+ FROM pr_notes WHERE entity_type=$1 AND entity_id=$2
+ ORDER BY pinned DESC, created_at DESC LIMIT 500`,
+ [String(entity_type), Number(entity_id)]);
+}
+
+async function add({ tenant_id, entity_type, entity_id, body }, user) {
+ const b = String(body || '').trim();
+ if (!b) throw new Error('note body required');
+ if (!ENTITY_TYPES.has(String(entity_type))) throw new Error('unknown entity_type');
+ const row = (await db.query(
+ `INSERT INTO pr_notes (tenant_id, entity_type, entity_id, body, author_user_id, author_name)
+ VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
+ [Number(tenant_id) || 1, String(entity_type), Number(entity_id), b.slice(0, 8000),
+ user && user.id ? user.id : null, (user && (user.name || user.email)) || 'admin'])).rows[0];
+ await audit.log({ actor: (user && user.email) || 'admin', action: 'note.add', entity_type: 'note', entity_id: row.id, detail: { on: entity_type + ':' + entity_id } });
+ // Surface in the entity's activity/correspondence timeline too (best-effort).
+ audit.activity({ entity_type: String(entity_type), entity_id: Number(entity_id), activity: 'note_added',
+ detail: { note_id: row.id, preview: b.slice(0, 140) }, actor: (user && user.email) || 'admin' }).catch(() => {});
+ return row;
+}
+
+async function update(id, body, user) {
+ const b = String(body || '').trim();
+ if (!b) throw new Error('note body required');
+ const row = (await db.query(`UPDATE pr_notes SET body=$2 WHERE id=$1 RETURNING *`, [Number(id), b.slice(0, 8000)])).rows[0];
+ if (!row) throw new Error('not found');
+ await audit.log({ actor: (user && user.email) || 'admin', action: 'note.update', entity_type: 'note', entity_id: Number(id) });
+ return row;
+}
+
+async function togglePin(id) {
+ const row = (await db.query(`UPDATE pr_notes SET pinned = NOT pinned WHERE id=$1 RETURNING *`, [Number(id)])).rows[0];
+ if (!row) throw new Error('not found');
+ return row;
+}
+
+async function remove(id, user) {
+ await db.query(`DELETE FROM pr_notes WHERE id=$1`, [Number(id)]);
+ await audit.log({ actor: (user && user.email) || 'admin', action: 'note.delete', entity_type: 'note', entity_id: Number(id) });
+ return { ok: true };
+}
+
+module.exports = { list, add, update, togglePin, remove, ENTITY_TYPES };
← 14e137d5 fix(news-dedup): newsSig amount normalization strips commas
·
back to Rentv
·
harden(news-dedup): Cody gate — word-boundary the single-cha b83fd44b →