[object Object]

← back to Rentv 2026

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

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 &amp; 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 &amp; 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 &amp; 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 2026  ·  harden(news-dedup): Cody gate — word-boundary the single-cha b83fd44b →