← back to Discontinued Agent

poller.js

385 lines

#!/usr/bin/env node
// discontinued-agent — poller daemon.
//
// Watches info@designerwallcoverings.com for vendor replies to DW's
// "Priority Request - Samples over 10 days old" sample-follow-up emails.
// When a vendor EXPLICITLY says a product is discontinued, it stamps the
// wallpaper record discontinued in FileMaker and closes the open sample order
// (so the >10-day follow-up system stops re-asking).
//
// Modes:
//   node poller.js            -> daemon: loops every POLL_INTERVAL_SEC seconds
//   node poller.js --once     -> run a single scan and exit
//
// SAFETY: unless DISCO_AUTOCOMMIT=1, this NEVER commits a FileMaker write. It
// stages the dry-run diff, logs it, and (for QUEUE cases) parks a memo.
//
// Cost: George (Gmail) + FileMaker Cloud are local/self-hosted = $0 per call.

import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

import * as George from './lib/george.js';
import * as FM from './lib/filemaker.js';
import {
  htmlToText,
  isDwOutbound,
  extractMfrNumbers,
  classifyReply,
  hashMsg,
} from './lib/parse.js';

const __dir = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = join(__dir, 'data');
const PROCESSED_FILE = join(DATA_DIR, 'processed-threads.json');
const LEDGER_FILE = join(DATA_DIR, 'ledger.jsonl');

const AUTOCOMMIT = process.env.DISCO_AUTOCOMMIT === '1';
const QUEUE_DIR = process.env.QUEUE_DIR || join(process.env.HOME || '', '.claude/yolo-queue/pending-approval');
const GMAIL_SEARCH =
  process.env.GMAIL_SEARCH ||
  'subject:"Priority Request - Samples over 10 days old" newer_than:60d';
const POLL_INTERVAL_SEC = Number(process.env.POLL_INTERVAL_SEC || 10);
const MAX_RESULTS = Number(process.env.MAX_RESULTS || 100);
// Completion report: email a summary to info@ after any run that took action.
const REPORT_TO = process.env.DISCO_REPORT_TO || 'info@designerwallcoverings.com';
const REPORT_ENABLED = process.env.DISCO_REPORT !== '0';

function log(...a) {
  console.log(new Date().toISOString(), '[disco]', ...a);
}

// ---- state ----
function loadProcessed() {
  try {
    return JSON.parse(readFileSync(PROCESSED_FILE, 'utf8'));
  } catch {
    return {};
  }
}
function saveProcessed(state) {
  mkdirSync(DATA_DIR, { recursive: true });
  writeFileSync(PROCESSED_FILE, JSON.stringify(state, null, 2));
}
function appendLedger(entry) {
  mkdirSync(DATA_DIR, { recursive: true });
  appendFileSync(LEDGER_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
}

function queueMemo(name, body) {
  mkdirSync(QUEUE_DIR, { recursive: true });
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
  const path = join(QUEUE_DIR, `discontinued-agent-${name}-${stamp}.md`);
  writeFileSync(path, body);
  return path;
}

// ---- thread processing ----

// Build the {threadId -> [messages]} grouping from a flat list, ordered oldest->newest.
function groupByThread(messages) {
  const g = new Map();
  for (const m of messages) {
    if (!g.has(m.threadId)) g.set(m.threadId, []);
    g.get(m.threadId).push(m);
  }
  for (const [, arr] of g) {
    arr.sort((a, b) => Number(a.internalDate || 0) - Number(b.internalDate || 0));
  }
  return g;
}

// Given full messages for a thread, split into DW outbound + vendor replies.
function splitThread(fullMsgs) {
  const outbound = [];
  const vendor = [];
  for (const m of fullMsgs) {
    if (isDwOutbound(m)) outbound.push(m);
    else vendor.push(m);
  }
  return { outbound, vendor };
}

// Decide the disposition of one thread. Returns a plan object (no side effects).
function planThread(threadId, fullMsgs) {
  const { outbound, vendor } = splitThread(fullMsgs);
  const outboundText = outbound.map((m) => htmlToText(m.body || '')).join('\n\n');
  const mfrNumbers = extractMfrNumbers(outboundText);

  // Newest vendor reply drives the verdict.
  const latestVendor = vendor[vendor.length - 1] || null;
  const vendorText = latestVendor ? htmlToText(latestVendor.body || '') : '';
  const vendorFrom = latestVendor ? latestVendor.from : null;
  const cls = classifyReply(vendorText);

  return {
    threadId,
    vendorFrom,
    vendorMsgId: latestVendor ? latestVendor.id : null,
    vendorHash: hashMsg(vendorText),
    mfrNumbers,
    verdict: cls.verdict,
    reasons: cls.reasons,
    outboundCount: outbound.length,
    vendorCount: vendor.length,
    vendorText,
  };
}

// Execute the FileMaker writes for a COMMIT plan (dry-run unless AUTOCOMMIT).
async function applyCommit(plan) {
  const today = FM.fmToday();
  const F = FM.FIELDS;
  const perMfr = [];

  for (const mfr of plan.mfrNumbers) {
    let rows;
    try {
      rows = await FM.findByMfrPattern(mfr);
    } catch (e) {
      perMfr.push({ mfr, error: `find failed: ${e.message}` });
      continue;
    }
    const discoWrites = [];
    const closeWrites = [];

    for (const rec of rows) {
      const { recordId, fields } = FM.recRow(rec);
      const blankDisco = !String(fields[F.dateDiscontinued] || '').trim();
      const isOpenOrder =
        String(fields[F.account] || fields[F.company] || '').trim() &&
        String(fields[F.requestPrinted] || '').trim() &&
        !String(fields[F.dateSampleSent] || '').trim();

      // WRITE 1 — discontinue (only if currently blank)
      if (blankDisco) {
        const r = await FM.updateRecord(recordId, { [F.dateDiscontinued]: today }, { dryRun: !AUTOCOMMIT });
        discoWrites.push({ recordId, field: F.dateDiscontinued, ...r });
      }
      // WRITE 2 — close open order (blank Date WP Sample Sent on an open request)
      if (isOpenOrder) {
        const r = await FM.updateRecord(recordId, { [F.dateSampleSent]: today }, { dryRun: !AUTOCOMMIT });
        closeWrites.push({ recordId, field: F.dateSampleSent, ...r });
      }
    }
    perMfr.push({ mfr, rowsMatched: rows.length, discoWrites, closeWrites });
  }
  return { today, perMfr };
}

async function processThread(threadId, headers, state, actions = []) {
  // Fetch full bodies for each message in the thread.
  const full = [];
  for (const h of headers) {
    try {
      full.push(await George.getMessage(h.id));
    } catch (e) {
      log('getMessage failed', h.id, e.message);
    }
  }
  if (full.length === 0) return;

  const plan = planThread(threadId, full);

  // processed-state key: threadId + hash of the vendor message.
  const key = `${threadId}:${plan.vendorHash}`;
  if (state[key]) return; // already handled this exact vendor message

  if (plan.verdict === 'SKIP') {
    // Mark processed so we don't re-read it every cycle, but log nothing heavy.
    state[key] = { at: new Date().toISOString(), verdict: 'SKIP' };
    return;
  }

  if (plan.verdict === 'QUEUE') {
    const memoBody = [
      `# discontinued-agent — QUEUED for Steve's review`,
      ``,
      `- **When:** ${new Date().toISOString()}`,
      `- **Thread:** ${threadId}`,
      `- **Vendor:** ${plan.vendorFrom || 'unknown'}`,
      `- **Why queued (not auto-discontinued):** ${plan.reasons.join('; ')}`,
      `- **Candidate mfr#(s) in thread:** ${plan.mfrNumbers.join(', ') || '(none extracted)'}`,
      ``,
      `## Vendor reply (excerpt)`,
      '```',
      plan.vendorText.slice(0, 1500),
      '```',
      ``,
      `No FileMaker writes were made. Review and, if a discontinuation is warranted,`,
      `stamp \`Date Discontinued\` + close the open order manually or re-run with a`,
      `confirmed mfr# via the skill.`,
    ].join('\n');
    const memoPath = queueMemo(`queue-${threadId}`, memoBody);
    appendLedger({
      threadId,
      vendor: plan.vendorFrom,
      verdict: 'QUEUE',
      reasons: plan.reasons,
      mfrNumbers: plan.mfrNumbers,
      memoPath,
      fieldsWritten: [],
    });
    state[key] = { at: new Date().toISOString(), verdict: 'QUEUE', memoPath };
    actions.push({ verdict: 'QUEUE', threadId, vendor: plan.vendorFrom, mfrNumbers: plan.mfrNumbers, reasons: plan.reasons });
    log('QUEUE', threadId, plan.vendorFrom, '->', plan.reasons.join('; '));
    return;
  }

  // COMMIT path — but only if we have at least one clean mfr# to act on.
  if (plan.mfrNumbers.length === 0) {
    const memoPath = queueMemo(
      `nomfr-${threadId}`,
      `# discontinued-agent — discontinuation stated but no mfr# mapped\n\n` +
        `- Thread: ${threadId}\n- Vendor: ${plan.vendorFrom}\n` +
        `- The vendor reply says discontinued but no manufacturer number could be\n` +
        `  cleanly extracted from the DW outbound. Queued for Steve.\n\n` +
        '## Vendor reply\n```\n' + plan.vendorText.slice(0, 1500) + '\n```\n'
    );
    appendLedger({ threadId, vendor: plan.vendorFrom, verdict: 'QUEUE_NO_MFR', reasons: plan.reasons, mfrNumbers: [], memoPath, fieldsWritten: [] });
    state[key] = { at: new Date().toISOString(), verdict: 'QUEUE_NO_MFR', memoPath };
    actions.push({ verdict: 'QUEUE_NO_MFR', threadId, vendor: plan.vendorFrom, mfrNumbers: [], reasons: plan.reasons });
    log('QUEUE(no mfr#)', threadId, plan.vendorFrom);
    return;
  }

  const result = await applyCommit(plan);
  const fieldsWritten = [];
  const recordIds = [];
  for (const pm of result.perMfr) {
    for (const w of [...(pm.discoWrites || []), ...(pm.closeWrites || [])]) {
      recordIds.push(w.recordId);
      fieldsWritten.push({ recordId: w.recordId, field: w.field, committed: !!w.committed, changes: w.changes });
    }
  }

  appendLedger({
    threadId,
    vendor: plan.vendorFrom,
    verdict: AUTOCOMMIT ? 'COMMIT' : 'STAGED(dry-run)',
    reasons: plan.reasons,
    mfrNumbers: plan.mfrNumbers,
    today: result.today,
    recordIds,
    fieldsWritten,
    autocommit: AUTOCOMMIT,
  });

  // Only mark processed when we actually committed. In dry-run mode we leave it
  // un-processed so a later run with DISCO_AUTOCOMMIT=1 can complete the write.
  if (AUTOCOMMIT) {
    state[key] = { at: new Date().toISOString(), verdict: 'COMMIT', recordIds };
  }
  actions.push({
    verdict: AUTOCOMMIT ? 'COMMIT' : 'STAGED',
    threadId,
    vendor: plan.vendorFrom,
    mfrNumbers: plan.mfrNumbers,
    recordIds,
  });
  log(
    AUTOCOMMIT ? 'COMMIT' : 'STAGED(dry-run)',
    threadId,
    plan.vendorFrom,
    'mfr#', plan.mfrNumbers.join(','),
    'rows', recordIds.length
  );
}

// Build + send the run-completion report to info@ (only when actions occurred).
async function sendRunReport(actions) {
  if (!REPORT_ENABLED || actions.length === 0) return;
  const byVerdict = (v) => actions.filter((a) => a.verdict === v);
  const committed = [...byVerdict('COMMIT'), ...byVerdict('STAGED')];
  const queued = [...byVerdict('QUEUE'), ...byVerdict('QUEUE_NO_MFR')];
  const line = (a) =>
    `<li><b>${a.vendor || 'unknown vendor'}</b> — mfr# ${(a.mfrNumbers || []).join(', ') || '(none)'}` +
    (a.recordIds ? ` — ${a.recordIds.length} row(s)` : '') +
    (a.reasons ? ` — <i>${a.reasons.join('; ')}</i>` : '') +
    ` <span style="color:#888">[thread ${a.threadId}]</span></li>`;
  const mode = AUTOCOMMIT ? 'AUTO-COMMITTED to FileMaker' : 'STAGED (dry-run — DISCO_AUTOCOMMIT is OFF)';
  const body = [
    `<p><b>discontinued-agent run report</b> — ${new Date().toISOString()}</p>`,
    `<p>Discontinuations ${mode}: <b>${committed.length}</b> · Queued for review: <b>${queued.length}</b></p>`,
    committed.length ? `<p><b>Discontinued / closed:</b></p><ul>${committed.map(line).join('')}</ul>` : '',
    queued.length ? `<p><b>Queued for Steve (not auto-written):</b></p><ul>${queued.map(line).join('')}</ul>` : '',
    `<p style="color:#888">Ledger: data/ledger.jsonl · Queue memos: ~/.claude/yolo-queue/pending-approval/</p>`,
  ].join('\n');
  try {
    await George.sendMail({
      to: REPORT_TO,
      subject: `discontinued-agent — ${committed.length} discontinued, ${queued.length} queued`,
      body,
    });
    log(`report emailed to ${REPORT_TO} (${committed.length} disco / ${queued.length} queued)`);
  } catch (e) {
    log('WARN report email failed:', e.message);
  }
}

async function scanOnce() {
  const state = loadProcessed();
  let headers;
  try {
    headers = await George.listMessages(GMAIL_SEARCH, { maxResults: MAX_RESULTS });
  } catch (e) {
    log('George list failed:', e.message);
    return;
  }
  const threads = groupByThread(headers);
  log(`scanned ${headers.length} msgs across ${threads.size} threads`);

  const actions = [];
  for (const [threadId, msgs] of threads) {
    try {
      await processThread(threadId, msgs, state, actions);
    } catch (e) {
      log('thread error', threadId, e.message);
    }
  }
  saveProcessed(state);
  await sendRunReport(actions);
}

async function main() {
  const once = process.argv.includes('--once');
  log(`starting. autocommit=${AUTOCOMMIT ? 'ON' : 'OFF (dry-run/stage only)'} search="${GMAIL_SEARCH}"`);

  // Health preflight (non-fatal).
  try {
    const h = await George.health();
    log('George health:', h.status || 'ok');
  } catch (e) {
    log('WARN George health check failed:', e.message);
  }
  try {
    const p = await FM.ping();
    log('FileMaker ping:', p.ok ? `ok (${p.database} @ ${p.host})` : 'unexpected');
  } catch (e) {
    log('WARN FileMaker ping failed:', e.message);
  }

  if (once) {
    await scanOnce();
    log('single scan complete.');
    return;
  }

  // Daemon loop — every POLL_INTERVAL_SEC seconds, never overlapping.
  log(`daemon loop every ${POLL_INTERVAL_SEC}s`);
  for (;;) {
    try {
      await scanOnce();
    } catch (e) {
      log('scan error:', e.message);
    }
    await new Promise((r) => setTimeout(r, POLL_INTERVAL_SEC * 1000));
  }
}

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