[object Object]

← back to Rentv

PR CRM notes: attach to deals too (string entity_id) + deals.html wiring

30df559ddd3fbfd8eeb4e5d8264ef3a3d14d025c · 2026-08-06 10:33:56 -0700 · Steve Abrams

- migration 009: widen pr_notes.entity_id bigint→text so notes attach to
  string-keyed entities (deal ids like 'md<ts>'/feed slugs), not just numeric
  org/person ids — keeps the widget truly generic
- notes.js: entity_id treated as text; activity-timeline row only recorded for
  numeric ids (pr_activities.entity_id is bigint)
- routes: pass entity_id through as text (no Number() coercion)
- deals.html: load pr-shared.js, add a Notes section to the edit modal shown
  when editing a persisted deal; mount PR.notes(el,'deal',id)
- verified: deal string-id add/list 200 + org numeric-id regression 200

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 30df559ddd3fbfd8eeb4e5d8264ef3a3d14d025c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 10:33:56 2026 -0700

    PR CRM notes: attach to deals too (string entity_id) + deals.html wiring
    
    - migration 009: widen pr_notes.entity_id bigint→text so notes attach to
      string-keyed entities (deal ids like 'md<ts>'/feed slugs), not just numeric
      org/person ids — keeps the widget truly generic
    - notes.js: entity_id treated as text; activity-timeline row only recorded for
      numeric ids (pr_activities.entity_id is bigint)
    - routes: pass entity_id through as text (no Number() coercion)
    - deals.html: load pr-shared.js, add a Notes section to the edit modal shown
      when editing a persisted deal; mount PR.notes(el,'deal',id)
    - verified: deal string-id add/list 200 + org numeric-id regression 200
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/deals.html                              | 10 ++++++++++
 src/pr/index.js                                |  4 ++--
 src/pr/migrations/009_notes_entity_id_text.sql | 12 ++++++++++++
 src/pr/services/notes.js                       | 16 ++++++++++------
 4 files changed, 34 insertions(+), 8 deletions(-)

diff --git a/public/deals.html b/public/deals.html
index dede2468..22783334 100644
--- a/public/deals.html
+++ b/public/deals.html
@@ -180,6 +180,10 @@
         <label>Image URL<input id="f_image" placeholder="https://… (optional)"></label>
         <label>Summary / notes<textarea id="f_summary" placeholder="Optional details about the transaction"></textarea></label>
       </form>
+      <div id="deal-notes-wrap" style="padding:2px 22px 14px;display:none">
+        <h3 style="font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--sub);margin:6px 0 10px">Notes <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--sub)">(team notes — timestamped &amp; attributed)</span></h3>
+        <div id="deal-notethread"></div>
+      </div>
       <div class="mf">
         <span class="msg" id="mMsg"></span>
         <span><button class="btn ghost" id="mCancel">Cancel</button> <button class="btn" id="mSave">Save deal</button></span>
@@ -187,6 +191,7 @@
     </div>
   </div>
 
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
 <script>
 const $=s=>document.querySelector(s);
 const MONTHS={Jan:0,Feb:1,Mar:2,Apr:3,May:4,Jun:5,Jul:6,Aug:7,Sep:8,Oct:9,Nov:10,Dec:11};
@@ -319,12 +324,17 @@ const F=['id','txn_type','address','city','state','property_type','amount_label'
 function openAdd(){
   $('#mTitle').textContent='Add deal'; $('#mMsg').textContent='';
   F.forEach(k=>{const el=$('#f_'+k);if(el)el.value='';});
+  $('#deal-notes-wrap').style.display='none'; // no id yet — notes need a saved deal to attach to
   $('#ov').classList.add('on'); $('#f_txn_type').focus();
 }
 function openEdit(d){
   if(!d)return; $('#mTitle').textContent='Edit deal'; $('#mMsg').textContent='';
   F.forEach(k=>{const el=$('#f_'+k);if(el)el.value=d[k]||'';});
   if(!d.close_date){const dt=dateOf(d);if(dt)$('#f_close_date').value=dt.toISOString().slice(0,10);}
+  // Notes attach to the persisted deal by its (string) id — the same reusable widget as org/broker/person.
+  const nw=$('#deal-notes-wrap');
+  if(d.id&&window.PR&&PR.notes){ nw.style.display='block'; PR.notes($('#deal-notethread'), 'deal', String(d.id)); }
+  else nw.style.display='none';
   $('#ov').classList.add('on'); $('#f_txn_type').focus();
 }
 function closeModal(){$('#ov').classList.remove('on');}
diff --git a/src/pr/index.js b/src/pr/index.js
index 0b84f5f3..319510aa 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -201,12 +201,12 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
   // 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) }) });
+    res.json({ rows: await notes.list({ entity_type: req.params.entityType, entity_id: 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 },
+      { tenant_id: req.prAuth.tenant.id, entity_type: b.entity_type, entity_id: b.entity_id, body: b.body },
       req.prAuth.user);
     res.json(row);
   }));
diff --git a/src/pr/migrations/009_notes_entity_id_text.sql b/src/pr/migrations/009_notes_entity_id_text.sql
new file mode 100644
index 00000000..b62cdc3d
--- /dev/null
+++ b/src/pr/migrations/009_notes_entity_id_text.sql
@@ -0,0 +1,12 @@
+-- Notes attach to ANY entity, and some entities key on non-numeric ids (deal ids are strings
+-- like 'md<timestamp>' or feed/corpus slugs). Widen entity_id from bigint → text so a note can
+-- point at an organization ("1"), a person ("42"), OR a deal ("mdlz3k9f"). Forward-only + idempotent.
+DO $$
+BEGIN
+  IF EXISTS (
+    SELECT 1 FROM information_schema.columns
+     WHERE table_name='pr_notes' AND column_name='entity_id' AND data_type <> 'text'
+  ) THEN
+    EXECUTE 'ALTER TABLE pr_notes ALTER COLUMN entity_id TYPE text USING entity_id::text';
+  END IF;
+END $$;
diff --git a/src/pr/services/notes.js b/src/pr/services/notes.js
index 6453d02c..9fcc9bd7 100644
--- a/src/pr/services/notes.js
+++ b/src/pr/services/notes.js
@@ -12,22 +12,26 @@ async function list({ entity_type, entity_id }) {
     `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)]);
+    [String(entity_type), String(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 eid = String(entity_id);
   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),
+    [Number(tenant_id) || 1, String(entity_type), eid, 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(() => {});
+  await audit.log({ actor: (user && user.email) || 'admin', action: 'note.add', entity_type: 'note', entity_id: row.id, detail: { on: entity_type + ':' + eid } });
+  // Surface in the entity's activity/correspondence timeline too (best-effort). pr_activities.entity_id
+  // is bigint, so only record for numeric-id entities (org/person); deal string ids are skipped.
+  if (/^\d+$/.test(eid)) {
+    audit.activity({ entity_type: String(entity_type), entity_id: Number(eid), activity: 'note_added',
+      detail: { note_id: row.id, preview: b.slice(0, 140) }, actor: (user && user.email) || 'admin' }).catch(() => {});
+  }
   return row;
 }
 

← 81770f55 rentv front page: split single left drawer into 2 corner ham  ·  back to Rentv  ·  nav: add /deals-log (Deals Log) to map hamburger + Explore d 1280b163 →