← 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
M public/deals.htmlM src/pr/index.jsA src/pr/migrations/009_notes_entity_id_text.sqlM src/pr/services/notes.js
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 & 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 →