[object Object]

← back to Rc Fm Callnotes

initial scaffold: RC->FM call notes sync

270c5467b826db9b8714d12ff29b9beb2bd63787 · 2026-07-03 04:19:50 -0700 · Steve Abrams

Files touched

Diff

commit 270c5467b826db9b8714d12ff29b9beb2bd63787
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 3 04:19:50 2026 -0700

    initial scaffold: RC->FM call notes sync
---
 .gitignore             |   7 +++
 lib/fm-test-helpers.js |  10 ++++
 lib/fm.js              |  84 +++++++++++++++++++++++++++
 lib/rc.js              |  71 +++++++++++++++++++++++
 package.json           |   6 ++
 sync-calls.mjs         | 153 +++++++++++++++++++++++++++++++++++++++++++++++++
 test-run.mjs           |  83 +++++++++++++++++++++++++++
 7 files changed, 414 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..607f4cd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+.env*
+data/rc-config.json
+data/state.json
+tmp/
+*.log
+.DS_Store
diff --git a/lib/fm-test-helpers.js b/lib/fm-test-helpers.js
new file mode 100644
index 0000000..f0d194c
--- /dev/null
+++ b/lib/fm-test-helpers.js
@@ -0,0 +1,10 @@
+// Direct pass-throughs for the test harness (create/get against whitelisted files).
+import { readFileSync } from 'node:fs';
+
+const FM_DIR = '/Users/stevestudio2/Projects/filemaker-mcp';
+for (const line of readFileSync(`${FM_DIR}/.env`, 'utf8').split('\n')) {
+  const m = line.match(/^([A-Z_]+)=(.*)$/);
+  if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
+}
+const fm = await import(`${FM_DIR}/src/fm-client.js`);
+export const { createRecord, findRecords, getRecord, deleteRecord: deleteRecordSafe } = fm;
diff --git a/lib/fm.js b/lib/fm.js
new file mode 100644
index 0000000..f848598
--- /dev/null
+++ b/lib/fm.js
@@ -0,0 +1,84 @@
+// Thin wrapper over the filemaker-mcp Data API client (whitelisted files only).
+// Loads that project's .env so Claris credentials stay in one place.
+import { readFileSync } from 'node:fs';
+
+const FM_DIR = '/Users/stevestudio2/Projects/filemaker-mcp';
+for (const line of readFileSync(`${FM_DIR}/.env`, 'utf8').split('\n')) {
+  const m = line.match(/^([A-Z_]+)=(.*)$/);
+  if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
+}
+
+const fm = await import(`${FM_DIR}/src/fm-client.js`);
+export const { findRecords, createRecord, updateRecord, listRecords, getRecord } = fm;
+
+export const CLIENTS_LAYOUT = 'Main Menu Copy';   // exposes Phone + Notes (writable)
+export const INVOICE_LAYOUT = 'PACKING LIST !!';  // exposes Invoice + Internal notes
+
+// Digits-only canonical form, US numbers reduced to 10 digits.
+export function normPhone(raw) {
+  let d = String(raw || '').replace(/\D/g, '');
+  if (d.length === 11 && d.startsWith('1')) d = d.slice(1);
+  return d;
+}
+
+// Common ways a 10-digit US number appears in the Clients file.
+export function phoneVariants(digits10) {
+  if (digits10.length !== 10) return [`*${digits10}*`];
+  const a = digits10.slice(0, 3), b = digits10.slice(3, 6), c = digits10.slice(6);
+  return [
+    `*${a}-${b}-${c}*`,
+    `*(${a}) ${b}-${c}*`,
+    `*(${a})${b}-${c}*`,
+    `*${a}.${b}.${c}*`,
+    `*${a} ${b} ${c}*`,
+    `*${a}${b}${c}*`,
+  ];
+}
+
+// Find a client by phone number; returns first match or null.
+export async function findClientByPhone(rawNumber) {
+  const digits = normPhone(rawNumber);
+  if (digits.length < 7) return null;
+  const query = phoneVariants(digits).map((v) => ({ Phone: v }));
+  try {
+    const res = await findRecords('Clients', CLIENTS_LAYOUT, query, { limit: 3 });
+    return res.data?.[0] || null;
+  } catch (e) {
+    if (e.fmCode === '401') return null; // 401 = no records match
+    throw e;
+  }
+}
+
+// Append text to a client's Notes (read-modify-write; newest entry on top).
+export async function appendClientNote(recordId, existingNotes, entry) {
+  const merged = entry + (existingNotes ? `\n\n${existingNotes}` : '');
+  return updateRecord('Clients', CLIENTS_LAYOUT, recordId, { Notes: merged }, { dryRun: false });
+}
+
+export async function createClientFromCall({ name, phone, note }) {
+  const fieldData = {
+    Company: name || 'Unknown caller',
+    Name: name || '',
+    Phone: phone,
+    Notes: note,
+    'Date Added': new Date().toLocaleDateString('en-US'),
+  };
+  // 'Date Added' isn't on Main Menu Copy — only send fields the layout exposes.
+  delete fieldData['Date Added'];
+  return createRecord('Clients', CLIENTS_LAYOUT, fieldData, { dryRun: false });
+}
+
+export async function findInvoiceByNumber(invoiceNo) {
+  try {
+    const res = await findRecords('invoice', INVOICE_LAYOUT, { Invoice: `==${invoiceNo}` }, { limit: 2 });
+    return res.data?.[0] || null;
+  } catch (e) {
+    if (e.fmCode === '401') return null;
+    throw e;
+  }
+}
+
+export async function appendInvoiceInternalNote(recordId, existingNotes, entry) {
+  const merged = entry + (existingNotes ? `\n${existingNotes}` : '');
+  return updateRecord('invoice', INVOICE_LAYOUT, recordId, { 'Internal notes': merged }, { dryRun: false });
+}
diff --git a/lib/rc.js b/lib/rc.js
new file mode 100644
index 0000000..5e748d9
--- /dev/null
+++ b/lib/rc.js
@@ -0,0 +1,71 @@
+// RingCentral REST client — JWT grant (same app as /root/DW-Agents/ringcentral-agent).
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const __dir = dirname(fileURLToPath(import.meta.url));
+const CFG = JSON.parse(readFileSync(join(__dir, '..', 'data', 'rc-config.json'), 'utf8'));
+
+let session = null; // { token, exp }
+
+export async function getToken() {
+  const now = Date.now();
+  if (session && now < session.exp) return session.token;
+  const res = await fetch(`${CFG.serverUrl}/restapi/oauth/token`, {
+    method: 'POST',
+    headers: {
+      Authorization: 'Basic ' + Buffer.from(`${CFG.clientId}:${CFG.clientSecret}`).toString('base64'),
+      'Content-Type': 'application/x-www-form-urlencoded',
+    },
+    body: new URLSearchParams({
+      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+      assertion: CFG.jwtToken,
+    }),
+  });
+  const json = await res.json();
+  if (!res.ok) throw new Error(`RC auth failed: ${JSON.stringify(json).slice(0, 300)}`);
+  session = { token: json.access_token, exp: now + (json.expires_in - 60) * 1000 };
+  return session.token;
+}
+
+export async function rc(path, { method = 'GET', body, query } = {}) {
+  const token = await getToken();
+  const url = new URL(`${CFG.serverUrl}${path}`);
+  for (const [k, v] of Object.entries(query || {})) url.searchParams.set(k, v);
+  const res = await fetch(url, {
+    method,
+    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  if (res.status === 204) return {};
+  const json = await res.json().catch(() => ({}));
+  if (!res.ok) {
+    const err = new Error(`RC ${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
+    err.status = res.status;
+    throw err;
+  }
+  return json;
+}
+
+// Company-wide call log (detailed view carries per-leg info).
+export function callLog({ dateFrom, dateTo, perPage = 100 } = {}) {
+  const query = { view: 'Detailed', perPage: String(perPage) };
+  if (dateFrom) query.dateFrom = dateFrom;
+  if (dateTo) query.dateTo = dateTo;
+  return rc('/restapi/v1.0/account/~/call-log', { query });
+}
+
+// Personal address-book contacts on the authorized extension (caller-ID source).
+export function listContacts({ page = 1, perPage = 100 } = {}) {
+  return rc('/restapi/v1.0/account/~/extension/~/address-book/contact', {
+    query: { page: String(page), perPage: String(perPage) },
+  });
+}
+export function createContact(contact) {
+  return rc('/restapi/v1.0/account/~/extension/~/address-book/contact', {
+    method: 'POST', body: contact,
+  });
+}
+export function deleteContact(id) {
+  return rc(`/restapi/v1.0/account/~/extension/~/address-book/contact/${id}`, { method: 'DELETE' });
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2f495ad
--- /dev/null
+++ b/package.json
@@ -0,0 +1,6 @@
+{
+  "name": "rc-fm-callnotes",
+  "version": "1.0.0",
+  "type": "module",
+  "description": "RingCentral -> FileMaker call notes sync + lazy caller-ID contact sync"
+}
diff --git a/sync-calls.mjs b/sync-calls.mjs
new file mode 100644
index 0000000..b05e72f
--- /dev/null
+++ b/sync-calls.mjs
@@ -0,0 +1,153 @@
+// RingCentral -> FileMaker call-notes sync.
+// Every run: pull completed external calls since the last checkpoint, and for each
+//   1. match the external number against Clients (phone-format variants)
+//   2. matched  -> stamp a dated call note into the client's Notes
+//      no match -> create a new client carrying all call info in Notes
+//   3. any invoice numbers found in note text -> stamp the invoice's Internal notes
+//   4. push the caller into the RC address book so their name shows on future calls
+// Append-only against FileMaker; a kill file stops the loop.
+import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { callLog, listContacts, createContact } from './lib/rc.js';
+import {
+  findClientByPhone, appendClientNote, createClientFromCall,
+  findInvoiceByNumber, appendInvoiceInternalNote, normPhone,
+} from './lib/fm.js';
+
+const __dir = dirname(fileURLToPath(import.meta.url));
+const STATE = join(__dir, 'data', 'state.json');
+const KILL = join(__dir, 'STOP');
+const LOG = (...a) => console.log(new Date().toISOString(), ...a);
+
+if (existsSync(KILL)) { LOG('STOP file present — exiting.'); process.exit(0); }
+
+const state = existsSync(STATE)
+  ? JSON.parse(readFileSync(STATE, 'utf8'))
+  : { lastSync: new Date(Date.now() - 60 * 60 * 1000).toISOString(), seen: [] };
+const seen = new Set(state.seen || []);
+
+const OWN_NUMBERS = new Set(['8883734564', '2133149912']); // DW main + showroom lines
+
+const fmtWhen = (iso) => new Date(iso).toLocaleString('en-US', {
+  timeZone: 'America/Los_Angeles',
+  year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
+});
+const fmtDur = (s) => (s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`);
+const fmtPhone = (d) => (d.length === 10 ? `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}` : d);
+
+function externalParty(rec) {
+  const side = rec.direction === 'Inbound' ? rec.from : rec.to;
+  const num = side?.phoneNumber || '';
+  return { number: num, name: side?.name || '' };
+}
+
+function buildNote(rec, party) {
+  const lines = [
+    `[${fmtWhen(rec.startTime)}] ${rec.direction} call ${rec.direction === 'Inbound' ? 'from' : 'to'} ${fmtPhone(normPhone(party.number))}` +
+    (party.name ? ` (${party.name})` : ''),
+    `Result: ${rec.result} · Duration: ${fmtDur(rec.duration || 0)}`,
+  ];
+  if (rec.result === 'Missed') lines.push('MISSED CALL — needs follow-up');
+  if (rec.result === 'Voicemail') lines.push('Went to voicemail');
+  if (rec.recording) lines.push('Call was recorded');
+  if (rec._noteText) lines.push(`Notes: ${rec._noteText}`);
+  lines.push('(auto-logged from RingCentral)');
+  return lines.join('\n');
+}
+
+// Invoice numbers referenced in note text, e.g. "invoice 102387" / "inv# 102387".
+function invoiceRefs(text) {
+  const out = new Set();
+  for (const m of String(text || '').matchAll(/\b(?:invoice|inv)\W{0,3}(\d{5,6})\b/gi)) out.add(m[1]);
+  return [...out];
+}
+
+async function ensureRcContact(party, client) {
+  const digits = normPhone(party.number);
+  if (digits.length !== 10) return 'skip (non-US number)';
+  // Already in the address book?
+  let page = 1;
+  for (;;) {
+    const res = await listContacts({ page, perPage: 250 });
+    const hit = (res.records || []).find((c) =>
+      [c.businessPhone, c.mobilePhone, c.homePhone].some((p) => normPhone(p) === digits));
+    if (hit) return `already present (#${hit.id})`;
+    if (!res.navigation?.nextPage) break;
+    page += 1;
+    if (page > 40) break; // personal book cap safety
+  }
+  const f = client?.fieldData || {};
+  const company = f.Company?.trim();
+  const personal = (f.Name || party.name || '').trim();
+  const [firstName, ...rest] = (personal || company || 'Unknown caller').split(/\s+/);
+  const created = await createContact({
+    firstName, lastName: rest.join(' ') || undefined,
+    company: company || undefined,
+    businessPhone: `+1${digits}`,
+    notes: f['Account Number'] ? `DW account ${f['Account Number']}` : 'Added by call-notes sync',
+  });
+  return `created (#${created.id})`;
+}
+
+export async function processCall(rec, { dryRun = false } = {}) {
+  const party = externalParty(rec);
+  const digits = normPhone(party.number);
+  const out = { id: rec.id, when: rec.startTime, direction: rec.direction, number: fmtPhone(digits), actions: [] };
+  if (!digits || OWN_NUMBERS.has(digits) || digits.length < 7) {
+    out.actions.push('skipped (internal/anonymous)');
+    return out;
+  }
+  const note = buildNote(rec, party);
+
+  const client = await findClientByPhone(party.number);
+  if (client) {
+    out.client = `${client.fieldData.Company || client.fieldData.Name} (acct ${client.fieldData['Account Number']})`;
+    if (!dryRun) await appendClientNote(client.recordId, client.fieldData.Notes, note);
+    out.actions.push('note appended to existing client');
+  } else {
+    if (!dryRun) {
+      const created = await createClientFromCall({ name: party.name, phone: fmtPhone(digits), note });
+      out.client = `NEW client created (recordId ${created.recordId})`;
+    } else out.client = 'NEW client would be created';
+    out.actions.push('new client created with call info in Notes');
+  }
+
+  for (const inv of invoiceRefs(rec._noteText)) {
+    const invoice = await findInvoiceByNumber(inv);
+    if (invoice) {
+      if (!dryRun) await appendInvoiceInternalNote(invoice.recordId, invoice.fieldData['Internal notes'], note.split('\n').slice(0, 2).join(' — '));
+      out.actions.push(`invoice ${inv}: internal note stamped`);
+    } else out.actions.push(`invoice ${inv}: not found, skipped`);
+  }
+
+  try {
+    const c = await ensureRcContact(party, client);
+    out.actions.push(`RC caller-ID contact: ${c}`);
+  } catch (e) { out.actions.push(`RC contact sync failed: ${e.message.slice(0, 120)}`); }
+  return out;
+}
+
+// ---- main ----
+const isMain = process.argv[1] === fileURLToPath(import.meta.url);
+if (isMain) {
+  const dateFrom = state.lastSync;
+  const res = await callLog({ dateFrom, perPage: 100 });
+  const cutoff = Date.now() - 2 * 60 * 1000; // leave in-flight calls for next pass
+  const records = (res.records || []).filter((r) =>
+    r.result && r.result !== 'In Progress' &&
+    new Date(r.startTime).getTime() + (r.duration || 0) * 1000 < cutoff &&
+    !seen.has(r.id));
+  LOG(`window from ${dateFrom}: ${records.length} new completed call(s)`);
+  let newest = state.lastSync;
+  for (const rec of records.sort((a, b) => a.startTime.localeCompare(b.startTime))) {
+    try {
+      const r = await processCall(rec);
+      LOG(JSON.stringify(r));
+      seen.add(rec.id);
+      if (rec.startTime > newest) newest = rec.startTime;
+    } catch (e) { LOG(`ERROR on call ${rec.id}: ${e.message}`); }
+  }
+  writeFileSync(STATE, JSON.stringify({ lastSync: newest, seen: [...seen].slice(-500) }, null, 2));
+  LOG('done.');
+}
diff --git a/test-run.mjs b/test-run.mjs
new file mode 100644
index 0000000..af63e68
--- /dev/null
+++ b/test-run.mjs
@@ -0,0 +1,83 @@
+// End-to-end test with TEST DATA ONLY (all records marked "TEST — DELETE ME").
+// Exercises: client match -> note append, no-match -> client create,
+// invoice reference -> Internal notes stamp, RC caller-ID contact create/verify/delete.
+import { createRecord, findRecords, getRecord, deleteRecordSafe } from './lib/fm-test-helpers.js';
+import { processCall } from './sync-calls.mjs';
+import { listContacts, deleteContact } from './lib/rc.js';
+import { normPhone } from './lib/fm.js';
+
+const results = [];
+const say = (s) => { console.log(s); results.push(s); };
+
+// --- setup: TEST client + TEST invoice ---
+say('1) Creating TEST client (Clients file)…');
+const testClient = await createRecord('Clients', 'Main Menu Copy', {
+  Company: 'TEST — DELETE ME (Claude call-notes)',
+  Name: 'Test Client',
+  Phone: '(818) 555-0142',
+  Notes: '',
+}, { dryRun: false });
+say(`   created recordId ${testClient.recordId}`);
+
+say('2) Creating TEST invoice 999998 (invoice file)…');
+const testInvoice = await createRecord('invoice', 'PACKING LIST !!', {
+  Invoice: '999998',
+  'SOLD NAME': 'TEST — DELETE ME (Claude call-notes)',
+  'Internal notes': '',
+}, { dryRun: false });
+say(`   created recordId ${testInvoice.recordId}`);
+
+// --- test A: known caller -> note appended to existing client ---
+say('3) Simulating inbound call from the TEST client number (818-555-0142)…');
+const callA = {
+  id: 'TEST-CALL-A', direction: 'Inbound', result: 'Accepted', duration: 245,
+  startTime: new Date().toISOString(),
+  from: { phoneNumber: '+18185550142', name: 'TEST CALLER' },
+  to: { phoneNumber: '+18883734564' },
+};
+say('   ' + JSON.stringify(await processCall(callA)));
+
+// --- test B: unknown caller -> new client created ---
+say('4) Simulating inbound call from an UNKNOWN number (818-555-0177)…');
+const callB = {
+  id: 'TEST-CALL-B', direction: 'Inbound', result: 'Missed', duration: 14,
+  startTime: new Date().toISOString(),
+  from: { phoneNumber: '+18185550177', name: 'TEST UNKNOWN DELETE ME' },
+  to: { phoneNumber: '+18883734564' },
+};
+say('   ' + JSON.stringify(await processCall(callB)));
+
+// --- test C: call note mentioning the TEST invoice -> Internal notes stamped ---
+say('5) Simulating call with a note that references invoice 999998…');
+const callC = {
+  id: 'TEST-CALL-C', direction: 'Inbound', result: 'Accepted', duration: 180,
+  startTime: new Date().toISOString(),
+  from: { phoneNumber: '+18185550142', name: 'TEST CALLER' },
+  to: { phoneNumber: '+18883734564' },
+  _noteText: 'Client called about invoice 999998 — asked for ship date and tracking.',
+};
+say('   ' + JSON.stringify(await processCall(callC)));
+
+// --- verify what landed ---
+say('6) Reading back what landed…');
+const clientBack = await getRecord('Clients', 'Main Menu Copy', testClient.recordId);
+say('   TEST client Notes now:\n' + clientBack.fieldData.Notes.split('\n').map((l) => '   | ' + l).join('\n'));
+const invBack = await getRecord('invoice', 'PACKING LIST !!', testInvoice.recordId);
+say('   TEST invoice Internal notes now:\n' + String(invBack.fieldData['Internal notes']).split('\n').map((l) => '   | ' + l).join('\n'));
+
+// --- clean up RC test contacts (leave FM records for Steve to eyeball) ---
+say('7) Cleaning TEST contacts out of the RingCentral address book…');
+let cleaned = 0;
+for (let page = 1; page <= 40; page++) {
+  const res = await listContacts({ page, perPage: 250 });
+  for (const c of res.records || []) {
+    const nums = [c.businessPhone, c.mobilePhone, c.homePhone].map(normPhone);
+    if (nums.includes('8185550142') || nums.includes('8185550177')) {
+      await deleteContact(c.id); cleaned++;
+      say(`   deleted RC test contact #${c.id} (${c.firstName || ''} ${c.lastName || ''})`);
+    }
+  }
+  if (!res.navigation?.nextPage) break;
+}
+say(`   ${cleaned} RC test contact(s) removed — live address book left untouched.`);
+say('DONE — FileMaker TEST records left in place for review (all marked TEST — DELETE ME).');

(oldest)  ·  back to Rc Fm Callnotes  ·  call-notes sync live: client match/create, invoice notes, RC 300fc6f →