← back to Dw Pitch Followup
lib/fm-notes.js
126 lines
// 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): it belongs
// to the client's FIRST related invoice record. Writing that related field
// THROUGH the Clients "Projects" layout gets rejected by the Data API (FM 960),
// so we resolve the invoice number from the client's Invoice portal, then read
// and write the invoice record DIRECTLY in the invoice DB via the
// "CFA order detail" layout (exposes Internal notes(1..4); verified the
// recordIds match the Clients-side portal — same underlying table).
//
// 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 CLIENT_LAYOUT = 'Projects'; // Clients layout with the Invoice portal
const INVOICE_LAYOUT = 'CFA order detail'; // invoice layout exposing Internal notes(1..4)
// 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', CLIENT_LAYOUT, { 'Account Number': `==${account}` }, { limit: 1 });
const rec = (r.records || [])[0];
if (!rec) return null;
const inv = (rec.portalData?.Invoice || [])[0] || null;
const invoice = inv ? String(inv['Invoice::Invoice'] || '').trim() : '';
const base = { account: String(account), invoice: invoice || null, invoiceRecordId: null, notes: '', extraReps: [] };
if (!invoice) return base;
const ir = await client.findRecords('invoice', INVOICE_LAYOUT, { Invoice: `==${invoice}` }, { limit: 1 });
const irec = (ir.records || [])[0];
if (!irec) return base;
const f = irec.fieldData || {};
return {
...base,
invoiceRecordId: irec.recordId,
notes: fromFm(f['Internal notes(1)']),
// reps 2-4 are rarely used but shown read-only when present
extraReps: [2, 3, 4].map((n) => fromFm(f[`Internal notes(${n})`])).filter(Boolean),
};
}
// Batch read for the card chips: one OR-query per ~50 accounts against the
// Clients Projects layout, which exposes BOTH the invoice # (Invoice portal
// row 1) and the note itself (related Invoice::Internal notes rep 1) — so the
// whole board paints without hammering FileMaker per-card.
export async function getNotesBatch(accounts) {
const out = {};
const uniq = [...new Set(accounts.map((a) => String(a).trim()).filter((a) => /^\d+$/.test(a)))];
for (let i = 0; i < uniq.length; i += 50) {
const slice = uniq.slice(i, i + 50);
let recs = [];
try {
const r = await client.findRecords('Clients', CLIENT_LAYOUT,
slice.map((a) => ({ 'Account Number': `==${a}` })), { limit: slice.length * 2 });
recs = r.records || [];
} catch (e) {
if (String(e.fmCode) !== '401') throw e; // 401 = no matches → skip chunk
}
for (const rec of recs) {
const acct = String(rec.fieldData['Account Number'] || '').trim();
if (!acct || out[acct]) continue;
const inv = (rec.portalData?.Invoice || [])[0] || null;
out[acct] = {
invoice: inv ? (String(inv['Invoice::Invoice'] || '').trim() || null) : null,
notes: fromFm(rec.fieldData['Invoice::Internal notes']),
};
}
}
return out;
}
// 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 {
// REP-1 WRITE FIX: a repeating field's rep 1 must be set via the BARE field name. FileMaker's
// Data API accepts "Internal notes(1)" with success code 0 but SILENTLY IGNORES it — the write
// no-ops while reporting committed:true (this caused notes to log-as-saved but never persist).
await client.updateRecord(
'invoice', INVOICE_LAYOUT, cur.invoiceRecordId,
{ 'Internal notes': toFm(next) },
{ dryRun: false },
);
// VERIFY: updateRecord's committed:true only means "PATCH didn't error", not "field changed".
// Read the record back and confirm the note is actually there — never report a save we can't see.
const check = await getNotes(account);
if (!check || !String(check.notes || '').includes(line)) {
throw new Error('FileMaker accepted the write but the note did not persist — NOT saved');
}
return { ...cur, notes: check.notes, added: line };
} finally {
if (prevRO !== undefined) process.env.FM_READONLY = prevRO;
}
}