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