← back to Filemaker Mcp
lib/notify.js
94 lines
import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { georgeAuthHeader, GEORGE_URL } from './george-auth.js';
const NOTIFY_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const FLAGGED_QUEUE = join(NOTIFY_ROOT, 'data', 'flagged-skus.jsonl');
const GEORGE = GEORGE_URL;
const GEORGE_AUTH = georgeAuthHeader(); // fleet cred, resolved from canonical DW-MCP/.env (rotated 2026-07-13)
// Idempotency key for a flagged SKU: one row per (order, invoice, sku).
const flagKey = (orderId, invoiceNumber, sku) => `${orderId}:${invoiceNumber}:${sku}`;
// Read the queue's existing idempotency keys so re-runs don't double-append.
function existingFlagKeys() {
try {
return new Set(readFileSync(FLAGGED_QUEUE, 'utf8').split('\n').filter(Boolean)
.map((l) => { try { return JSON.parse(l).key; } catch { return null; } }).filter(Boolean));
} catch { return new Set(); }
}
// Surface a SKU that ensureWallpaper() could NOT resolve (no findable mfr number)
// LOUDLY instead of swallowing it. The invoice line is STILL created by the caller;
// this only flags. Three surfaces: (1) console.warn, (2) idempotent JSONL queue at
// data/flagged-skus.jsonl (drainable by scripts/drain-flagged-skus.mjs), (3) internal
// Slack + George email to Steve. INTERNAL ops signal only — never customer-facing.
export async function flagSku({ orderId, orderName, invoiceNumber, sku, dwSku, reason }) {
const key = flagKey(orderId, invoiceNumber, sku);
const custPO = String(orderName || '').replace(/^#/, '');
// (1) loud console line
console.warn(`⚠️ FLAGGED SKU — order ${orderName || orderId} · invoice ${invoiceNumber} · sku ${sku} (dwSku ${dwSku}) · ${reason} · blank vid/mfr written; run: node scripts/ensure-wallpaper-for-order.mjs ${custPO}`);
// (2) idempotent JSONL append
let queued = false;
try {
const dir = dirname(FLAGGED_QUEUE);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
if (!existingFlagKeys().has(key)) {
appendFileSync(FLAGGED_QUEUE, JSON.stringify({
key, ts: new Date().toISOString(),
orderId, orderName, custPO, invoiceNumber, sku, dwSku, reason, resolved: false,
}) + '\n');
queued = true;
}
} catch (e) { console.warn('flagSku: queue append failed:', e.message); }
// (3) internal notify — Slack + George email (both best-effort, never throw)
const msg = `SKU has no findable mfr number — order ${orderName || orderId}, invoice #${invoiceNumber}, SKU ${sku} (${reason}). Invoice line created with blank VID + Detail 1 Mfr Number. Create/complete its FileMaker WALLPAPER master, then run: node scripts/ensure-wallpaper-for-order.mjs ${custPO} (or: node scripts/drain-flagged-skus.mjs)`;
await postFlagToSlack(msg).catch(() => {});
await emailFlagToSteve({ invoiceNumber, sku, custPO, reason, msg }).catch(() => {});
return { key, queued };
}
async function postFlagToSlack(text) {
const token = process.env.SLACK_BOT_TOKEN;
const channel = process.env.SLACK_GENERAL_CHANNEL;
if (!token || !channel) return { ok: false, error: 'slack not configured' };
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ channel, text: `🚩 *Invoice flag* — ${text}`, unfurl_links: false }),
});
return res.json();
}
async function emailFlagToSteve({ invoiceNumber, sku, custPO, reason, msg }) {
const res = 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 invoice flag: SKU ${sku} on invoice ${invoiceNumber} (Cust PO ${custPO}) has no mfr number`,
body: `<p>An invoice line was created with a blank VID + Detail 1 Mfr Number because its SKU has no findable manufacturer number.</p><p><b>${msg}</b></p><p>Reason: ${reason}</p>` }),
});
return res.ok;
}
// Post a new-order alert to Slack #general: client name, invoice#, time, total, order link.
export async function postOrderToSlack({ order, invoiceNumber, total, clientName }) {
const token = process.env.SLACK_BOT_TOKEN;
const channel = process.env.SLACK_GENERAL_CHANNEL;
if (!token || !channel) return { ok: false, error: 'slack not configured' };
const store = process.env.SHOPIFY_STORE;
const when = new Date(order.created_at || Date.now()).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
const link = `https://${store}/admin/orders/${order.id}`;
const text = `🛎️ *New order* — ${clientName} · Invoice #${invoiceNumber} · ${when} · *$${total}* · <${link}|View order #${order.order_number}>`;
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ channel, text, unfurl_links: false }),
});
return res.json();
}