← back to Filemaker Mcp

bin/shopify-sync.js

104 lines

#!/usr/bin/env node
// Standing watcher: poll info@ (via George) for new Designer Wallcoverings
// Shopify order emails, enrich each from the Shopify API, and add genuinely-new
// customers to the FileMaker Clients file (deduped by email/phone/name).
// Auto-commits and reports. Run on a schedule (launchd).
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { syncOrder } from '../lib/sync-core.js';
import { georgeAuthHeader, GEORGE_URL } from '../lib/george-auth.js';

const __dir = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dir, '..');
// load .env
for (const l of readFileSync(join(ROOT, '.env'), 'utf8').split('\n')) {
  const m = l.match(/^([A-Z0-9_]+)=(.*)$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
}

const DATA = join(ROOT, 'data');
if (!existsSync(DATA)) mkdirSync(DATA, { recursive: true });
const STATE_F = join(DATA, 'sync-state.json');
const LEDGER = join(DATA, 'sync-ledger.jsonl');

const GEORGE = GEORGE_URL;
const GEORGE_AUTH = georgeAuthHeader(); // fleet cred, resolved from canonical DW-MCP/.env (rotated 2026-07-13)
const DW_SENDER = 'store+1541177456@t.shopifyemail.com'; // the DW customer-order sender (excludes vendor confirmations)
const STORE = process.env.FM_SHOPIFY_STORE || process.env.SHOPIFY_STORE;
const SHOP_TOKEN = process.env.SHOPIFY_ORDERS_TOKEN;

const log = (...a) => console.log(new Date().toISOString(), ...a);

function loadState() { try { return JSON.parse(readFileSync(STATE_F, 'utf8')); } catch { return { maxProcessed: 0, processed: [] }; } }
function saveState(s) { writeFileSync(STATE_F, JSON.stringify(s, null, 2)); }

// 1) pull recent DW order emails from info@ via George; parse "Order #NNNNN placed by NAME"
async function newOrderNumbers(state) {
  const q = encodeURIComponent(`from:${DW_SENDER} subject:"placed by" newer_than:14d`);
  const r = await fetch(`${GEORGE}/api/search?account=info&q=${q}&maxResults=40`, { headers: { Authorization: GEORGE_AUTH } });
  if (!r.ok) throw new Error('George search failed: ' + r.status);
  const msgs = (await r.json()).messages || [];
  const nums = new Set();
  for (const m of msgs) {
    const mm = (m.subject || '').match(/Order\s+#(\d+)\s+placed by/i);
    if (mm) { const n = Number(mm[1]); if (n > (state.maxProcessed || 0) && !state.processed?.includes(n)) nums.add(n); }
  }
  return [...nums].sort((a, b) => a - b);
}

// 2) fetch the full order (address/phone/email) from Shopify by order number
async function fetchOrder(num) {
  const url = `https://${STORE}/admin/api/2024-10/orders.json?status=any&name=${encodeURIComponent('#' + num)}`;
  const r = await fetch(url, { headers: { 'X-Shopify-Access-Token': SHOP_TOKEN } });
  if (!r.ok) throw new Error(`Shopify fetch ${num} failed: ${r.status}`);
  const orders = (await r.json()).orders || [];
  return orders.find((o) => o.order_number === num) || orders[0] || null;
}

// optional: report a created client back to steve-office via George (internal send, allowed)
async function reportProcessed(results) {
  const done = results.filter((r) => r.action === 'processed');
  if (!done.length) return;
  const rows = done.map((c) => `Order #${c.orderNumber} → invoice ${c.invoiceNumber} ($${c.invoiceTotal}) · client ${c.client} acct ${c.accountNumber} — ${c.company}`).join('<br>');
  await fetch(`${GEORGE}/api/send`, {
    method: 'POST', headers: { Authorization: GEORGE_AUTH, 'Content-Type': 'application/json' },
    body: JSON.stringify({ account: 'steve-office', to: 'steve@designerwallcoverings.com',
      subject: `FileMaker: ${done.length} order(s) synced (client + invoice) from Shopify`,
      body: `<p>Auto-synced from Shopify orders (client ensured + invoice created + Slack alert):</p><p>${rows}</p>` }),
  }).catch((e) => log('report email failed:', e.message));
}

async function main() {
  if (!STORE || !SHOP_TOKEN) throw new Error('SHOPIFY_STORE / SHOPIFY_ORDERS_TOKEN not set in .env');
  const state = loadState();
  const nums = await newOrderNumbers(state);
  if (!nums.length) { log('no new orders.'); return; }
  log('new order numbers:', nums.join(', '));

  const results = [];
  for (const num of nums) {
    try {
      const order = await fetchOrder(num);
      if (!order) { log(`order ${num} not found in Shopify, skipping`); continue; }
      const res = await syncOrder(order, { commit: true });
      results.push(res);
      log(`#${num}: client=${res.client} acct ${res.accountNumber} (${res.company}) → invoice ${res.invoiceNumber} $${res.invoiceTotal} · slack=${res.slack}`);
      appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), ...res }) + '\n');
      state.processed = [...new Set([...(state.processed || []), num])].slice(-500);
      state.maxProcessed = Math.max(state.maxProcessed || 0, num);
      saveState(state);
    } catch (e) {
      // record the failure and mark the order seen so it neither retries forever
      // nor silently disappears — surfaces in the ledger for manual reprocessing
      log(`#${num} ERROR:`, e.message);
      appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), orderNumber: num, action: 'error', error: e.message }) + '\n');
      state.processed = [...new Set([...(state.processed || []), num])].slice(-500);
      state.maxProcessed = Math.max(state.maxProcessed || 0, num);
      saveState(state);
    }
  }
  await reportProcessed(results);
}

main().catch((e) => { log('FATAL', e.message); process.exit(1); });