← back to Rc Fm Callnotes

sync-calls.mjs

155 lines

// 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 { enrichWithTranscript } from './lib/transcribe.mjs';
import {
  findClientByPhone, appendClientNote, createClientFromCall,
  findInvoiceByNumber, appendInvoiceInternalNote, invoiceNotesOf, 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, invoiceNotesOf(invoice), note.split('\n').slice(0, 2).join(' — ') + (rec._noteText ? ` — ${rec._noteText}` : ''));
      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.');
}