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