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