[object Object]

← back to Dw Pitch Followup

auto-save: 2026-07-15T10:35:56 (2 files) — server.js lib/fm-notes.js

e35cf910e2901b8231ebd44aaec3641f1e774417 · 2026-07-15 10:35:57 -0700 · Steve Abrams

Files touched

Diff

commit e35cf910e2901b8231ebd44aaec3641f1e774417
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 15 10:35:57 2026 -0700

    auto-save: 2026-07-15T10:35:56 (2 files) — server.js lib/fm-notes.js
---
 lib/fm-notes.js | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js       | 30 ++++++++++++++++++++++
 2 files changed, 107 insertions(+)

diff --git a/lib/fm-notes.js b/lib/fm-notes.js
new file mode 100644
index 0000000..9ff7f3d
--- /dev/null
+++ b/lib/fm-notes.js
@@ -0,0 +1,77 @@
+// FileMaker "Internal notes" access — the ONE deliberate write path in this tool.
+//
+// The note Steve sees on the client screen in FileMaker Pro is the related
+// Invoice::Internal notes field (repeating, 4 reps — we use rep 1) shown on the
+// Clients "Projects" layout: it belongs to the FIRST related invoice record.
+// We read it from the client's Projects record and write it back through the
+// same layout using the Data API related-field syntax
+// (`Invoice::Internal notes.<relatedRecordId>`), so the web edit lands on
+// exactly the record FM Pro displays.
+//
+// UNLIKE lib/fm.js this module does NOT force FM_READONLY=1 — it is imported
+// only by server.js (never alongside the read-only build pipeline, which runs
+// in its own process). addNote() still clears/restores the flag defensively.
+
+import fs from 'node:fs';
+
+const FM_MCP_DIR = '/Users/macstudio3/Projects/filemaker-mcp';
+
+function loadFmEnv() {
+  const envPath = `${FM_MCP_DIR}/.env`;
+  if (!fs.existsSync(envPath)) throw new Error(`filemaker-mcp .env not found at ${envPath}`);
+  for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
+    const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
+    if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+  }
+}
+loadFmEnv();
+
+const client = await import(`${FM_MCP_DIR}/src/fm-client.js`);
+
+const LAYOUT = 'Projects'; // Clients layout that carries Invoice::Internal notes + the Invoice portal
+
+// FileMaker stores line breaks as \r; the browser wants \n.
+const fromFm = (s) => String(s ?? '').replace(/\r/g, '\n');
+const toFm = (s) => String(s ?? '').replace(/\r?\n/g, '\r');
+
+export async function getNotes(account) {
+  const r = await client.findRecords('Clients', LAYOUT, { 'Account Number': `==${account}` }, { limit: 1 });
+  const rec = (r.records || [])[0];
+  if (!rec) return null;
+  const inv = (rec.portalData?.Invoice || [])[0] || null;
+  return {
+    account: String(account),
+    clientRecordId: rec.recordId,
+    invoice: inv ? String(inv['Invoice::Invoice'] || '') : null,
+    invoiceRecordId: inv ? inv.recordId : null,
+    notes: fromFm(rec.fieldData['Invoice::Internal notes']),
+  };
+}
+
+// Prepend "MM/DD/YYYY - <note>" to Internal notes rep 1 (newest on top).
+export async function addNote(account, note) {
+  const text = String(note || '').trim();
+  if (!text) throw new Error('Empty note');
+  const cur = await getNotes(account);
+  if (!cur) throw new Error(`No FileMaker client record for account ${account}`);
+  if (!cur.invoiceRecordId) throw new Error(`Account ${account} has no related invoice — nowhere to write Internal notes`);
+
+  const d = new Date();
+  const stamp = `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
+  const line = `${stamp} - ${text}`;
+  const next = cur.notes ? `${line}\n${cur.notes}` : line;
+
+  const prevRO = process.env.FM_READONLY; // defensive: never let a stray read-only flag block or linger
+  delete process.env.FM_READONLY;
+  try {
+    const res = await client.updateRecord(
+      'Clients', LAYOUT, cur.clientRecordId,
+      { [`Invoice::Internal notes.${cur.invoiceRecordId}`]: toFm(next) },
+      { dryRun: false },
+    );
+    if (!res.committed) throw new Error(res.note || 'FileMaker did not commit the note');
+  } finally {
+    if (prevRO !== undefined) process.env.FM_READONLY = prevRO;
+  }
+  return { ...cur, notes: next, added: line };
+}
diff --git a/server.js b/server.js
index 0bd4359..611b311 100644
--- a/server.js
+++ b/server.js
@@ -798,6 +798,36 @@ app.get('/api/scan-unsubscribes', async (req, res) => {
   }
 });
 
+// ---- FileMaker Internal notes (the one deliberate FM write path in this tool) ----
+// GET  /api/notes?account=N  → { account, invoice, notes } — the Invoice::Internal notes
+//                              shown on the client's Projects screen in FM Pro.
+// POST /api/notes {account, note} → prepends "MM/DD/YYYY - note" to that field (rep 1).
+// lib/fm-notes.js is lazy-loaded so a missing FM cred can't take the whole viewer down.
+let fmNotesMod = null;
+async function fmNotes() { if (!fmNotesMod) fmNotesMod = await import('./lib/fm-notes.js'); return fmNotesMod; }
+app.get('/api/notes', async (req, res) => {
+  try {
+    const account = String(req.query.account || '').trim();
+    if (!/^\d+$/.test(account)) return res.status(400).json({ error: 'account required' });
+    const out = await (await fmNotes()).getNotes(account);
+    if (!out) return res.status(404).json({ error: `no client record for account ${account}` });
+    res.json({ account: out.account, invoice: out.invoice, notes: out.notes });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+app.post('/api/notes', async (req, res) => {
+  try {
+    const account = String(req.body?.account || '').trim();
+    const note = String(req.body?.note || '').trim();
+    if (!/^\d+$/.test(account)) return res.status(400).json({ error: 'account required' });
+    if (!note) return res.status(400).json({ error: 'note required' });
+    if (note.length > 2000) return res.status(400).json({ error: 'note too long (2000 max)' });
+    const out = await (await fmNotes()).addNote(account, note);
+    // Audit trail — every FM write from this tool is traceable (who/when/what).
+    try { fs.appendFileSync(path.join(DATA_DIR, 'notes-log.jsonl'), JSON.stringify({ ts: new Date().toISOString(), account, invoice: out.invoice, ip: clientIp(req), added: out.added }) + '\n'); } catch {}
+    res.json({ account: out.account, invoice: out.invoice, notes: out.notes, added: out.added });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 app.listen(PORT, '127.0.0.1', () => {
   console.log(`[dw-pitch-followup] listening on http://127.0.0.1:${PORT}  (auth ${BASIC_AUTH.split(':')[0]} / …)  letters via ${LETTER_MODEL}`);
 });

← e90255c pitch: scheduled refresh now also pre-triages the whole boar  ·  back to Dw Pitch Followup  ·  Add Internal-notes editor to pitch cards — 🗒 Notes button n 58c847e →