← 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)');
};