← back to Commercialrealestate
scripts/crcp-notes.js
124 lines
// crcp-notes.js — PER-USER private scratch notes for the CRCP deal-flow tool.
// Hybrid CRM model (Steve 2026-08-06, TK-10301): a loan officer's scratch notes on a specific
// entity are PRIVATE to that user, while the contact rolodex (agent-contacts in serve.js) stays a
// shared team asset. This module owns the notes half.
//
// SCOPES (2026-08-19): the same per-user note engine now backs two independent surfaces —
// • condo listings → data/condo-notes.json (routes: /api/condo-notes[/:id]) — original, unchanged
// • agent/broker profiles → data/agent-notes.json (routes: /api/agent-notes[/:id]) — NEW
// Each scope is a SEPARATE JSON file so an agent id can never collide with a condo listing id.
//
// Store shape (per scope file), namespaced by user key:
// { "_perUser": true, "users": { "<userKey>": { "<entityId>": { note, updated_at } } } }
// The condo store may still carry the OLD flat shape { "<listingId>": { note, updated_at } } from
// before per-user notes; on first load we auto-migrate that flat map under the FIRST user (Frank).
//
// Auth: notes are attributed to the signed-in account (crcp_sid session, via userOf). Reads with no
// session return an empty set (page still renders); writes with no session return 401 so the client
// can prompt a sign-in. Admins may read another user's notes with ?user=<key>.
//
// $0, local, reversible. Mount from serve.js: require('./crcp-notes')(app, ROOT, userOf);
'use strict';
const fs = require('fs');
const path = require('path');
module.exports = function mountNotes(app, ROOT, userOf) {
const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
// Which user owns migrated legacy notes — the first/only real user at migration time.
const FRANK = String(process.env.CRCP_SEED_USER || 'frank').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '');
// Identity resolution (2026-08-19): notes attribute to the crcp-accounts session (crcp_sid) when
// present; but a user on a Basic-gated CRCP instance (e.g. steve/jef215 on :9912) has NO account
// session, so fall back to the Basic-auth username, namespaced 'gate:<user>' to avoid colliding
// with account emails. Lets notes save without a second login; unchanged for Frank (crcp_sid).
function basicUser(req) {
const h = req.headers.authorization || ''; const [scheme, enc] = h.split(' ');
if (scheme === 'Basic' && enc) { try { const u = Buffer.from(enc, 'base64').toString().split(':')[0].trim().toLowerCase().replace(/[^a-z0-9._@\-]/g, ''); return u || null; } catch (_) {} }
return null;
}
function resolve(req) {
const u = userOf(req); if (u) return { key: u.email, admin: u.perm === 'admin' };
const b = basicUser(req); if (b) return { key: 'gate:' + b, admin: false };
return null;
}
// A scope = one JSON file + its route pair. `migrateLegacyFlat` only applies to the condo store
// (the agent store is new, so it never has a legacy flat shape to rescue).
function mountScope({ file, base, label, migrateLegacyFlat }) {
const FILE = path.join(ROOT, 'data', file);
// Load + normalize to the per-user shape. Auto-migrates a legacy flat map under FRANK's key.
function loadStore() {
let raw;
try { raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { _perUser: true, users: {} }; }
if (raw && raw._perUser && raw.users && typeof raw.users === 'object') return raw;
if (!migrateLegacyFlat) return { _perUser: true, users: {} };
// Legacy flat { id: {note, updated_at} } → wrap under FRANK. Guard against an empty/oddball file.
const legacy = (raw && typeof raw === 'object') ? raw : {};
const hasLegacy = Object.values(legacy).some(v => v && typeof v === 'object' && 'note' in v);
return { _perUser: true, users: hasLegacy ? { [FRANK]: legacy } : {} };
}
// Serialized read-modify-write with atomic rename (mirrors the agent-contacts / accounts store).
let _q = Promise.resolve();
function withStore(mutator) {
const run = _q.then(() => {
const store = loadStore();
const out = mutator(store);
const tmp = FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
fs.renameSync(tmp, FILE);
return out;
});
_q = run.then(() => {}, () => {});
return run;
}
const notesFor = (store, key) => (store.users && store.users[key]) || {};
// GET <base> → the signed-in user's notes map. Admin may pass ?user=<key>.
app.get(base, (req, res) => {
const who = resolve(req);
if (!who) return res.json({ notes: {}, signed_in: false });
let key = who.key;
if (req.query.user && who.admin) {
key = String(req.query.user).trim().toLowerCase().replace(/[^a-z0-9._@.\-]/g, '');
}
res.json({ notes: notesFor(loadStore(), key), signed_in: true, user: key });
});
// POST <base>/:id { note } → upsert (empty note deletes). Requires a session. Returns the saved
// record incl. updated_at so the client can render "last updated <date>" (the modification date).
app.post(base + '/:id', (req, res) => {
const who = resolve(req);
if (!who) return res.status(401).json({ error: 'sign in to save notes', signed_in: false });
const id = clip(req.params.id, 120); if (!id) return res.status(400).json({ error: 'no id' });
const note = clip((req.body || {}).note, 8000);
const key = who.key;
withStore(store => {
store.users = store.users || {};
const bucket = store.users[key] = store.users[key] || {};
if (note.trim()) bucket[id] = { note, updated_at: new Date().toISOString() };
else delete bucket[id];
return bucket[id];
}).then(rec => res.json({ ok: true, id, note: rec || null }))
.catch(e => res.status(500).json({ error: String(e && e.message || e) }));
});
console.log(`[crcp-notes] ${label} mounted at ${base} (per-user, timestamped)`);
}
// Condo listing notes — original surface, file + routes unchanged (back-compat).
mountScope({ file: 'condo-notes.json', base: '/api/condo-notes', label: 'condo-listing notes', migrateLegacyFlat: true });
// Agent / broker profile notes — NEW surface (Steve 2026-08-19: "on each agent page, provide space
// for NOTES and record any modification date and update"). Keyed by the profile's stable id
// (e.g. agent:<id|name>, broker:<id>). updated_at is the "modification date" the client shows.
mountScope({ file: 'agent-notes.json', base: '/api/agent-notes', label: 'agent/broker profile notes', migrateLegacyFlat: false });
// SFV pool-home listing notes — NEW surface (Steve 2026-08-19: "ability to take notes for any record
// within user and save"). Keyed by the listing's stable Redfin id (h.id). Works for the Basic-gated
// steve instance via the gate:<user> fallback above.
mountScope({ file: 'sfr-notes.json', base: '/api/sfr-notes', label: 'SFV pool-home notes', migrateLegacyFlat: false });
console.log('[crcp-notes] per-user private notes mounted (hybrid CRM: private notes + shared contacts)');
};