← 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
A lib/fm-notes.jsM server.js
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 →