← back to Ads Dashboard

server.js

305 lines

#!/usr/bin/env node
/**
 * ads-dashboard — host-aware internal ad-ops command center.
 *
 * One app serves two live hosts (barber.aa PUBLIC_HOSTS pattern):
 *   ads.designerwallcoverings.com  -> DW ad-ops
 *   ads.agentabrams.com            -> Abrams-portfolio ad-ops
 *
 * Zero external deps (node:http/fs/path/crypto only) so deploy = copy + `pm2 start`.
 * Basic-Auth gates everything except /healthz; the 401 IS the healthy gate.
 */
'use strict';

const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const PORT = parseInt(process.env.PORT || '9770', 10);
const AUTH_USER = process.env.ADS_USER || 'admin';
const AUTH_PASS = process.env.ADS_PASS || 'DW2024!';
const PUBLIC_DIR = path.join(__dirname, 'public');
const DATA_DIR = path.join(__dirname, 'data');

// ── minimal .env loader (prod-only secrets live here, gitignored) ────────────
(function loadEnv() {
  try {
    for (const line of fs.readFileSync(path.join(__dirname, '.env'), 'utf8').split('\n')) {
      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
      if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
    }
  } catch { /* no .env — fine in dev */ }
})();

// ── Shopify acquisition (DW real data — order source_name / referring_site) ──
const SHOP = process.env.DW_SHOP || 'designer-laboratory-sandbox.myshopify.com';
const ORDERS_TOKEN = process.env.SHOPIFY_ORDERS_TOKEN || '';
let _acqCache = { at: 0, data: null };

function classifyChannel(referrer, sourceName) {
  const h = String(referrer || '').toLowerCase();
  if (!h) return sourceName === 'web' ? 'Direct / None' : (sourceName || 'Other');
  if (/google\./.test(h)) return 'Google';
  if (/bing\./.test(h)) return 'Bing';
  if (/shop\.app|shopify/.test(h)) return 'Shop app';
  if (/chatgpt|openai/.test(h)) return 'ChatGPT';
  if (/facebook|fb\.|meta\./.test(h)) return 'Facebook';
  if (/instagram/.test(h)) return 'Instagram';
  if (/pinterest/.test(h)) return 'Pinterest';
  if (/bing|duckduck|yahoo/.test(h)) return 'Other search';
  if (/t\.co|twitter|x\.com/.test(h)) return 'X / Twitter';
  return 'Other referral';
}

async function fetchAcquisition() {
  if (!ORDERS_TOKEN) return { present: false, reason: 'SHOPIFY_ORDERS_TOKEN not set on this host', source: 'shopify-orders' };
  if (_acqCache.data && Date.now() - _acqCache.at < 10 * 60 * 1000) return _acqCache.data;
  const WINDOW_DAYS = 30;
  const sinceISO = new Date(Date.now() - WINDOW_DAYS * 864e5).toISOString();
  const url = `https://${SHOP}/admin/api/2024-10/orders.json?status=any&limit=250&order=created_at+desc&created_at_min=${encodeURIComponent(sinceISO)}&fields=id,created_at,cancelled_at,financial_status,source_name,referring_site,landing_site,total_price`;
  const r = await fetch(url, { headers: { 'X-Shopify-Access-Token': ORDERS_TOKEN } });
  if (!r.ok) return { present: false, reason: `shopify ${r.status}`, source: 'shopify-orders' };
  const j = await r.json();
  const raw = j.orders || [];
  // exclude cancelled — not a real acquisition; count refunds separately, net them from revenue
  const orders = raw.filter(o => !o.cancelled_at);
  const REFUNDED = new Set(['refunded', 'partially_refunded', 'voided']);
  const bySource = {}, byChannel = {}, byCampaign = {}, byDay = {};
  let revenue = 0, utmTagged = 0, refundedCount = 0, capped = raw.length >= 250;
  const round2 = (n) => Math.round(n * 100) / 100;
  const dates = [];
  for (const o of orders) {
    if (o.created_at) dates.push(o.created_at);
    const day = (o.created_at || '').slice(0, 10);
    if (day) { byDay[day] = byDay[day] || { orders: 0, revenue: 0 }; byDay[day].orders++; }
    const isRefund = REFUNDED.has(o.financial_status);
    if (isRefund) refundedCount++;
    const rev = isRefund ? 0 : Number(o.total_price || 0); // net of refunds/voids
    const ch = classifyChannel(o.referring_site, o.source_name);
    byChannel[ch] = byChannel[ch] || { orders: 0, revenue: 0 };
    byChannel[ch].orders++; byChannel[ch].revenue += rev;
    const s = o.source_name || '(none)';
    bySource[s] = (bySource[s] || 0) + 1;
    revenue += rev;
    if (day && byDay[day]) byDay[day].revenue += rev;
    // UTM attribution from the landing_site query string
    let camp = null, src = '?', med = '?';
    try {
      const q = new URL(o.landing_site || '', `https://${SHOP}`).searchParams;
      camp = q.get('utm_campaign'); src = q.get('utm_source') || '?'; med = q.get('utm_medium') || '?';
    } catch { /* malformed landing_site */ }
    if (camp) {
      utmTagged++;
      const key = camp + ' · ' + src;
      byCampaign[key] = byCampaign[key] || { campaign: camp, source: src, medium: med, automated: /product_sync|shop/i.test(med), orders: 0, revenue: 0 };
      byCampaign[key].orders++; byCampaign[key].revenue += rev;
    } else {
      const u = byCampaign['(unattributed)'] = byCampaign['(unattributed)'] || { campaign: '(unattributed — no utm_campaign)', source: '—', medium: '—', automated: false, orders: 0, revenue: 0 };
      u.orders++; u.revenue += rev;
    }
  }
  const channels = Object.entries(byChannel)
    .map(([channel, v]) => ({ channel, orders: v.orders, revenue: round2(v.revenue) }))
    .sort((a, b) => b.orders - a.orders);
  const campaigns = Object.values(byCampaign)
    .map(c => ({ ...c, revenue: round2(c.revenue) }))
    .sort((a, b) => b.orders - a.orders);
  const from = dates.length ? dates.reduce((a, b) => a < b ? a : b).slice(0, 10) : null;
  const to = dates.length ? dates.reduce((a, b) => a > b ? a : b).slice(0, 10) : null;
  const trend = Object.entries(byDay)
    .map(([date, v]) => ({ date, orders: v.orders, revenue: round2(v.revenue) }))
    .sort((a, b) => a.date < b.date ? -1 : 1);
  const data = {
    present: true, source: 'shopify-orders (net of refunds, excl. cancelled)', shop: SHOP,
    generated_at: new Date().toISOString(),
    window: capped ? `${from} → ${to} (capped at 250 orders — window may be shorter than ${WINDOW_DAYS}d)` : `${from} → ${to} (last ${WINDOW_DAYS}d)`,
    totals: { orders: orders.length, revenue: round2(revenue), utm_tagged: utmTagged, refunded_netted: refundedCount },
    capped, // true if the fetch hit the 250-order cap — trend tail may under-report
    channels, campaigns, trend, by_source: bySource,
  };
  _acqCache = { at: Date.now(), data };
  return data;
}

// ── Host → brand map ────────────────────────────────────────────────────────
// Add a hostname here (and register a vhost) — this is the PUBLIC_HOSTS registry.
// NOTE: ads.agentabrams.com is NOT ours — it belongs to `agent-ad-network`
// (:9939, TK-10131, Kickback.ai). Do NOT add it here or re-point its vhost.
// If an Abrams-side ad-ops host is ever needed, propose a DIFFERENT subdomain
// via pending-approval. This app serves ads.designerwallcoverings.com only.
const BRANDS = {
  'ads.designerwallcoverings.com': {
    key: 'dw',
    name: 'Designer Wallcoverings',
    short: 'DW Ad-Ops',
    accent: '#8b7355',
    domains: ['designerwallcoverings.com', 'apartmentwallpaper.com', 'philipperomano.com'],
  },
};
const DEFAULT_BRAND = {
  key: 'default', name: 'Ad-Ops', short: 'Ad-Ops', accent: '#555', domains: [],
};

function brandForHost(hostHeader) {
  const host = String(hostHeader || '').toLowerCase().split(':')[0];
  return BRANDS[host] || DEFAULT_BRAND;
}

// ── helpers ─────────────────────────────────────────────────────────────────
function readJSON(file, fallback) {
  try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), 'utf8')); }
  catch { return fallback; }
}

function timingSafeEqual(a, b) {
  const ab = Buffer.from(a), bb = Buffer.from(b);
  if (ab.length !== bb.length) return false;
  return crypto.timingSafeEqual(ab, bb);
}

function checkAuth(req) {
  const h = req.headers['authorization'] || '';
  if (!h.startsWith('Basic ')) return false;
  const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
  return timingSafeEqual(u || '', AUTH_USER) && timingSafeEqual(p || '', AUTH_PASS);
}

function send(res, code, body, headers = {}) {
  res.writeHead(code, { 'Cache-Control': 'no-store', ...headers });
  res.end(body);
}
function sendJSON(res, code, obj) {
  send(res, code, JSON.stringify(obj), { 'Content-Type': 'application/json' });
}

const MIME = {
  '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
  '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
  '.ico': 'image/x-icon', '.woff2': 'font/woff2',
};

// ── data endpoints (real sources where present, honest-empty otherwise) ───────
// Spend: read the global cost-ledger if it exists; summarize last 7d by provider.
function spendSummary() {
  const ledgerPath = path.join(process.env.HOME || '/root', '.claude', 'cost-ledger.jsonl');
  const out = { source: 'cost-ledger.jsonl', window_days: 7, total_usd: 0, by_provider: {}, present: false };
  try {
    const lines = fs.readFileSync(ledgerPath, 'utf8').trim().split('\n');
    out.present = true;
    const cutoff = fixedNowMs() - 7 * 864e5;
    for (const ln of lines) {
      let e; try { e = JSON.parse(ln); } catch { continue; }
      const t = Date.parse(e.ts || e.time || e.date || 0);
      if (t && t < cutoff) continue;
      const usd = Number(e.usd || e.cost || e.amount || 0);
      const prov = e.provider || e.service || 'unknown';
      out.total_usd += usd;
      out.by_provider[prov] = (out.by_provider[prov] || 0) + usd;
    }
    out.total_usd = Math.round(out.total_usd * 100) / 100;
  } catch { /* honest-empty: present stays false */ }
  return out;
}

// fixedNow: server boot time baseline (Date is fine at runtime on the server;
// the workflow-script restriction does not apply to a running node process).
function fixedNowMs() { return Date.now(); }

function apiOverview(brand) {
  const campaigns = readJSON(`campaigns.${brand.key}.json`, null)
    || readJSON('campaigns.json', { campaigns: [] });
  const list = Array.isArray(campaigns) ? campaigns : (campaigns.campaigns || []);
  const active = list.filter(c => (c.status || '').toLowerCase() === 'active');
  return {
    brand: brand.key,
    generated_at: new Date().toISOString(),
    counts: { campaigns: list.length, active: active.length, domains: brand.domains.length },
    domains: brand.domains,
  };
}

// ── request handler ───────────────────────────────────────────────────────────
const server = http.createServer((req, res) => {
  const brand = brandForHost(req.headers.host);
  const url = new URL(req.url, 'http://x');
  const pathname = url.pathname;

  // open health check (no auth) — the fleet probe reads this
  if (pathname === '/healthz') {
    return sendJSON(res, 200, {
      ok: true, service: 'ads-dashboard', brand: brand.key,
      host: (req.headers.host || '').split(':')[0], ts: new Date().toISOString(),
    });
  }

  // everything else is gated
  if (!checkAuth(req)) {
    return send(res, 401, 'Authentication required', {
      'WWW-Authenticate': 'Basic realm="Ad-Ops"',
      'Content-Type': 'text/plain',
    });
  }

  // API
  if (pathname === '/api/overview') return sendJSON(res, 200, apiOverview(brand));
  if (pathname === '/api/spend') return sendJSON(res, 200, spendSummary());
  if (pathname === '/api/signals') {
    return sendJSON(res, 200, readJSON(`signals.${brand.key}.json`, { source: 'advertising-signals', signals: [], present: false }));
  }
  if (pathname === '/api/campaigns') {
    const c = readJSON(`campaigns.${brand.key}.json`, null) || readJSON('campaigns.json', { campaigns: [] });
    return sendJSON(res, 200, Array.isArray(c) ? { campaigns: c } : c);
  }
  if (pathname === '/api/brand') return sendJSON(res, 200, brand);
  if (pathname === '/api/revenue') {
    if (brand.key !== 'dw') return sendJSON(res, 200, { present: false, reason: 'revenue is DW-only' });
    return fetchAcquisition().then(acq => {
      const fm = readJSON('fmpro.json', null);
      const streams = [];
      // Shopify — live
      streams.push({ stream: 'Shopify', present: !!acq.present, total: acq.present ? acq.totals.revenue : 0,
        orders: acq.present ? acq.totals.orders : 0, window: acq.window || null, as_of: acq.generated_at || null });
      // FMPro — pushed from Mac2 (aggregate only), stamped with freshness
      if (fm && fm.present) {
        const ageH = fm.generated_at ? (Date.now() - Date.parse(fm.generated_at)) / 36e5 : null;
        streams.push({ stream: 'FMPro invoicing', present: true, total: fm.total, orders: fm.orders,
          window: fm.window, as_of: fm.generated_at, stale: ageH != null && ageH > 25, age_hours: ageH != null ? Math.round(ageH * 10) / 10 : null });
      } else {
        streams.push({ stream: 'FMPro invoicing', present: false, total: 0, reason: fm ? 'truncated fetch' : 'no push yet (Mac2→Kamatera)' });
      }
      // PayPal — not connected (source TBD, Steve to confirm)
      streams.push({ stream: 'PayPal', present: false, total: 0, reason: 'not connected — source pending' });
      const combined = streams.filter(s => s.present).reduce((a, s) => a + (s.total || 0), 0);
      return sendJSON(res, 200, {
        present: true, generated_at: new Date().toISOString(),
        note: 'Shopify + FMPro are confirmed non-overlapping (Steve 2026-08-03). PayPal not yet connected.',
        combined_total: Math.round(combined * 100) / 100, streams,
      });
    }).catch(e => sendJSON(res, 200, { present: false, reason: 'error: ' + e.message }));
  }
  if (pathname === '/api/acquisition') {
    if (brand.key !== 'dw') return sendJSON(res, 200, { present: false, reason: 'acquisition data is DW-only' });
    return fetchAcquisition()
      .then(d => sendJSON(res, 200, d))
      .catch(e => sendJSON(res, 200, { present: false, reason: 'error: ' + e.message, source: 'shopify-orders' }));
  }

  // static
  let rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
  const file = path.join(PUBLIC_DIR, rel);
  if (!file.startsWith(PUBLIC_DIR)) return send(res, 403, 'forbidden');
  fs.readFile(file, (err, buf) => {
    if (err) {
      // SPA fallback
      return fs.readFile(path.join(PUBLIC_DIR, 'index.html'), (e2, idx) =>
        e2 ? send(res, 404, 'not found') : send(res, 200, idx, { 'Content-Type': MIME['.html'] }));
    }
    send(res, 200, buf, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
  });
});

server.listen(PORT, () => {
  console.log(`[ads-dashboard] listening on :${PORT}  hosts=${Object.keys(BRANDS).join(', ')}`);
});