← back to George Gmail

fleet-autoresponder-ui.js

108 lines

#!/usr/bin/env node
/**
 * Fleet Auto-Responder — admin UI dashboard.
 *
 * A small standalone Express viewer (127.0.0.1:9768) for the fleet
 * auto-responder. Deliberately NOT part of the George prod server — it
 * reads the responder's own data files (ledger + run-log + state) so it
 * needs no George deploy and no Gmail OAuth.
 *
 * Shows:
 *   - inquiries received per fleet domain
 *   - auto-replies sent (timestamp + recipient)
 *   - spam-skips with the reason each was skipped
 *   - the current reply template (HTML preview)
 *   - an on/off toggle for the responder
 *   - last-run time
 *
 * Data sources (all written by fleet-autoresponder.js):
 *   data/fleet-autoresponder-ledger.json   per-sender reply timestamps
 *   data/fleet-autoresponder-runlog.jsonl  append-only per-message records
 *   data/fleet-autoresponder-state.json    last-run + on/off toggle
 */
'use strict';

const fs = require('fs');
const path = require('path');
const express = require('express');

const responder = require('./fleet-autoresponder');
const { RUNLOG_PATH, STATE_PATH, LEDGER_PATH, FLEET,
        buildHtmlBody, buildPlainBody, loadState, saveState } = responder;

const PORT = process.env.FLEET_UI_PORT || 9768;
const app = express();
app.use(express.json());

// ─── Data readers ────────────────────────────────────────────────────────────
function readRunLog() {
  try {
    return fs.readFileSync(RUNLOG_PATH, 'utf8')
      .split('\n').filter(Boolean)
      .map(l => { try { return JSON.parse(l); } catch { return null; } })
      .filter(Boolean);
  } catch { return []; }
}
function readLedger() {
  try { return JSON.parse(fs.readFileSync(LEDGER_PATH, 'utf8')); }
  catch { return { replies: {} }; }
}

// ─── API ─────────────────────────────────────────────────────────────────────
app.get('/api/data', (req, res) => {
  const log = readRunLog();
  const state = loadState();
  const ledger = readLedger();

  const replies = log.filter(r => r.event === 'reply');
  const skips   = log.filter(r => r.event === 'skip');

  // Per-domain inquiry tally (a "received" inquiry = anything the responder
  // touched — replied OR skipped — that resolved to a fleet domain).
  const perDomain = {};
  for (const s of FLEET) perDomain[s.domain] = { domain: s.domain, siteName: s.siteName, received: 0, replied: 0, skipped: 0 };
  for (const r of log) {
    if (!r.fleetDomain || !perDomain[r.fleetDomain]) continue;
    perDomain[r.fleetDomain].received++;
    if (r.event === 'reply') perDomain[r.fleetDomain].replied++;
    if (r.event === 'skip')  perDomain[r.fleetDomain].skipped++;
  }

  res.json({
    state,
    fleetCount: FLEET.length,
    totals: {
      inquiries: log.length,
      replied: replies.length,
      skipped: skips.length,
      uniqueSenders: Object.keys(ledger.replies || {}).length,
    },
    perDomain: Object.values(perDomain).sort((a, b) => b.received - a.received),
    replies: replies.slice(-200).reverse(),
    skips: skips.slice(-200).reverse(),
  });
});

// Current reply template — rendered for a sample domain so the UI can preview.
app.get('/api/template', (req, res) => {
  const domain = (req.query.domain || (FLEET[0] && FLEET[0].domain) || 'example.com').toLowerCase();
  res.json({ domain, html: buildHtmlBody(domain), text: buildPlainBody(domain) });
});

// On/off toggle for the responder.
app.post('/api/toggle', (req, res) => {
  const state = loadState();
  state.enabled = !!req.body.enabled;
  state.toggledAt = new Date().toISOString();
  saveState(state);
  res.json({ ok: true, enabled: state.enabled });
});

app.get('/healthz', (_req, res) => res.json({ ok: true }));

app.use(express.static(path.join(__dirname, 'public-fleet-ui')));

app.listen(PORT, '127.0.0.1', () => {
  console.log(`[fleet-autoresponder-ui] listening on http://127.0.0.1:${PORT}`);
});