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