← back to Wpb Sales Dashboard

server.js

316 lines

#!/usr/bin/env node
/*
 * WPB Sales & Products Dashboard  —  zero-dependency Node HTTP server.
 *
 * Tracks the Wallpaper's Back design-selling operation across marketplaces:
 *   Designs (inventory)  ->  Listings (design placed on a marketplace)  ->  Sales (orders).
 *
 * Data model / sources (read live on every request, so edits to the underlying
 * pattern-vault files show up on refresh — no build step, no cache to bust):
 *   - Designs   : pattern-vault/data/catalog.json          (the pattern inventory)
 *   - Spoonflower listings (LIVE) : pattern-vault/data/spoonflower-uploads.json
 *   - Etsy listings (PLANNED)     : pattern-vault/data/etsy-listings-plan.json
 *   - Marketplace accounts        : ./config/marketplaces.json  (this repo)
 *   - Sales                       : ./data/sales-ledger.jsonl   (append-only, this repo)
 *
 * The sales ledger starts EMPTY on purpose — everything is pre-launch / PRIVATE.
 * The dashboard renders honestly at $0 and is wired to receive the first real order
 * via POST /api/sales (manual entry now; marketplace webhooks can POST the same shape later).
 */
'use strict';

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

// --- minimal .env loader (zero-dep; loads WEBHOOK_TOKEN etc. without dotenv) ---
try {
  const envRaw = fs.readFileSync(path.join(__dirname, '.env'), 'utf8');
  for (const line of envRaw.split('\n')) {
    const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
    if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
  }
} catch { /* no .env — fine */ }

const PORT = process.env.PORT || 9812;
const ROOT = __dirname;
const HOME = os.homedir();

// ---- source-of-truth paths (pattern-vault owns the product data) --------------
const PV = path.join(HOME, 'Projects', 'pattern-vault', 'data');
const SRC = {
  catalog:    path.join(PV, 'catalog.json'),
  spoonflower:path.join(PV, 'spoonflower-uploads.json'),
  etsyPlan:   path.join(PV, 'etsy-listings-plan.json'),
};
const CFG_MARKETS = path.join(ROOT, 'config', 'marketplaces.json');
const LEDGER      = path.join(ROOT, 'data', 'sales-ledger.jsonl');

// ---- basic auth (fleet convention: admin / DW2024!, the 401 IS the healthy gate) ---
const AUTH_USER = process.env.DASH_USER || 'admin';
const AUTH_PASS = process.env.DASH_PASS || 'DW2024!';

// ---- webhook shared secret (inbound sales receiver; NOT basic-auth) -----------
const WEBHOOK_TOKEN = process.env.WEBHOOK_TOKEN || '';

// Known marketplace keys (from config) so a webhook can't invent shops.
function marketplaceKeys() {
  const cfg = readJSON(CFG_MARKETS, { marketplaces: [] }).marketplaces || [];
  return new Set(cfg.map(m => m.key));
}
// Map many possible source field names onto one ledger row. Sources vary wildly
// (Etsy receipt, Zapier email-parse, a marketplace CSV row) — normalize aliases.
function normalizeSalePayload(b) {
  const pick = (...keys) => { for (const k of keys) if (b[k] != null && b[k] !== '') return b[k]; return undefined; };
  const marketplace = String(pick('marketplace', 'shop', 'channel', 'platform', 'source') || '').toLowerCase().trim();
  const gross = n(pick('gross', 'total', 'amount', 'order_total', 'grand_total', 'price', 'revenue'));
  const fee   = n(pick('fee', 'fees', 'commission', 'marketplace_fee', 'transaction_fee'));
  return {
    date: pick('date', 'created', 'created_at', 'timestamp', 'order_date') || new Date().toISOString(),
    marketplace,
    title: pick('title', 'design', 'product', 'listing', 'item', 'pattern') || null,
    design: pick('design', 'title', 'product', 'listing') || null,
    units: n(pick('units', 'quantity', 'qty', 'count') || 1),
    gross,
    fee,
    net: (pick('net') != null) ? n(pick('net')) : (gross - fee),
    currency: pick('currency', 'cur') || 'USD',
    orderId: pick('orderId', 'order_id', 'receipt_id', 'transaction_id', 'id') || null,
  };
}
// Idempotency: a webhook may retry — skip a row we already have (same marketplace+orderId).
function saleAlreadyRecorded(marketplace, orderId) {
  if (!orderId) return false;
  return readLedger().some(r => r.marketplace === marketplace && String(r.orderId) === String(orderId));
}

// ---- tiny helpers -------------------------------------------------------------
function readJSON(file, fallback) {
  try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
  catch (e) { return fallback; }
}
function readLedger() {
  try {
    return fs.readFileSync(LEDGER, 'utf8')
      .split('\n').map(l => l.trim()).filter(Boolean)
      .map(l => { try { return JSON.parse(l); } catch { return null; } })
      .filter(Boolean);
  } catch { return []; }
}
function n(x) { const v = Number(x); return Number.isFinite(v) ? v : 0; }

// ---- aggregation --------------------------------------------------------------
function buildSnapshot() {
  const marketsCfg = readJSON(CFG_MARKETS, { marketplaces: [] }).marketplaces || [];
  const marketByKey = Object.fromEntries(marketsCfg.map(m => [m.key, m]));

  // Designs = the pattern inventory
  const catalog = readJSON(SRC.catalog, []);
  const designs = (Array.isArray(catalog) ? catalog : []).map(d => ({
    id: d.id,
    title: d.title,
    category: d.category || d.kind || 'design',
    createdAt: d.createdAt || null,
    img: d.img || null,
    pricingStatus: d.pricingStatus || null,
    tiers: Array.isArray(d.licenseTiers) ? d.licenseTiers.map(t => t.priceUsd) : [],
  }));

  // Listings = a design placed on a marketplace
  const listings = [];

  // Spoonflower (LIVE uploads, currently private)
  const sf = readJSON(SRC.spoonflower, []);
  (Array.isArray(sf) ? sf : []).forEach(u => listings.push({
    id: `sf-${u.id}`,
    designId: u.id,
    title: u.title,
    marketplace: 'spoonflower',
    marketplaceName: 'Spoonflower',
    status: u.private ? 'private' : 'live',
    price: null,               // POD royalty — price set by Spoonflower per substrate
    url: u.url || null,
    image: null,
    brand: 'Designer Wallcoverings',
    createdAt: u.uploadedAt || null,
  }));

  // Etsy (PLANNED — in the listing plan, not yet pushed as drafts)
  const etsy = readJSON(SRC.etsyPlan, {});
  const etsyPrice = etsy && etsy.fixed ? n(etsy.fixed.price) : null;
  (etsy && Array.isArray(etsy.plan) ? etsy.plan : []).forEach((p, i) => {
    const title = (p.body && p.body.title) || `Etsy listing ${i + 1}`;
    listings.push({
      id: `etsy-plan-${i}`,
      designId: (p.imageFile || '').replace(/^_images\//, '').replace(/\.\w+$/, '') || null,
      title,
      marketplace: 'etsy',
      marketplaceName: 'Etsy',
      status: 'planned',
      price: etsyPrice,
      url: null,
      image: null,
      brand: "Wallpaper's Back",
      createdAt: null,
    });
  });

  // Sales ledger
  const sales = readLedger().map(s => ({
    date: s.date || null,
    marketplace: s.marketplace || 'unknown',
    marketplaceName: (marketByKey[s.marketplace] || {}).name || s.marketplace || 'Unknown',
    design: s.design || s.title || null,
    title: s.title || s.design || null,
    units: n(s.units || 1),
    gross: n(s.gross),
    fee: n(s.fee),
    net: s.net != null ? n(s.net) : (n(s.gross) - n(s.fee)),
    currency: s.currency || 'USD',
    orderId: s.orderId || null,
  }));

  // Per-marketplace rollup (listings + sales)
  const markets = marketsCfg.map(m => {
    const mListings = listings.filter(l => l.marketplace === m.key);
    const mSales = sales.filter(s => s.marketplace === m.key);
    return {
      ...m,
      listings: mListings.length,
      listingsLive: mListings.filter(l => l.status === 'live').length,
      listingsPrivate: mListings.filter(l => l.status === 'private').length,
      listingsPlanned: mListings.filter(l => l.status === 'planned').length,
      salesGross: mSales.reduce((a, s) => a + s.gross, 0),
      salesNet: mSales.reduce((a, s) => a + s.net, 0),
      unitsSold: mSales.reduce((a, s) => a + s.units, 0),
      orders: mSales.length,
    };
  });

  // KPIs
  const kpis = {
    designsTotal: designs.length,
    listingsTotal: listings.length,
    listingsLive: listings.filter(l => l.status === 'live').length,
    listingsPrivate: listings.filter(l => l.status === 'private').length,
    listingsPlanned: listings.filter(l => l.status === 'planned').length,
    marketplacesTotal: marketsCfg.length,
    marketplacesLive: marketsCfg.filter(m => m.status === 'live').length,
    salesGross: sales.reduce((a, s) => a + s.gross, 0),
    salesNet: sales.reduce((a, s) => a + s.net, 0),
    unitsSold: sales.reduce((a, s) => a + s.units, 0),
    ordersCount: sales.length,
  };

  // 14-day sales series (gross by day) — honest zeros when pre-launch
  const days = [];
  const today = new Date();
  for (let i = 13; i >= 0; i--) {
    const d = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - i));
    const key = d.toISOString().slice(0, 10);
    const dayGross = sales.filter(s => (s.date || '').slice(0, 10) === key).reduce((a, s) => a + s.gross, 0);
    days.push({ date: key, gross: dayGross });
  }

  return {
    generatedAt: new Date().toISOString(),
    kpis,
    markets,
    listings,
    designs,
    sales: sales.slice().reverse(), // newest first
    series: { sales14d: days },
    launched: kpis.ordersCount > 0,
  };
}

// ---- request handling ---------------------------------------------------------
function unauthorized(res) {
  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="WPB Sales"', 'Content-Type': 'text/plain' });
  res.end('Authentication required');
}
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 u === AUTH_USER && p === AUTH_PASS;
}
function send(res, code, body, type) {
  res.writeHead(code, { 'Content-Type': type || 'application/json', 'Cache-Control': 'no-store' });
  if (Buffer.isBuffer(body)) return res.end(body);           // static files: pass raw bytes, don't JSON-stringify
  res.end(typeof body === 'string' ? body : JSON.stringify(body));
}
function readBody(req) {
  return new Promise((resolve) => {
    let b = '';
    req.on('data', c => { b += c; if (b.length > 1e6) req.destroy(); });
    req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } });
  });
}

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

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://localhost:${PORT}`);
  const p = url.pathname;

  // health check is auth-exempt (so pm2/monitors get a clean 200)
  if (p === '/healthz') return send(res, 200, { ok: true, ts: Date.now() });

  // Inbound sales webhook — token-gated (NOT basic-auth, so external senders work).
  // Any source (Zapier/Make from a sale-email, a poller, manual curl) POSTs a sale here.
  if (p === '/webhook/sale' && req.method === 'POST') {
    const token = req.headers['x-webhook-token'] || url.searchParams.get('token') || '';
    if (!WEBHOOK_TOKEN || token !== WEBHOOK_TOKEN) return send(res, 401, { ok: false, error: 'bad or missing webhook token' });
    const row = normalizeSalePayload(await readBody(req));
    if (!row.marketplace || !marketplaceKeys().has(row.marketplace)) {
      return send(res, 400, { ok: false, error: `unknown marketplace "${row.marketplace}"; must be one of ${[...marketplaceKeys()].join(', ')}` });
    }
    if (row.gross === 0 && row.units === 0) return send(res, 400, { ok: false, error: 'need gross or units' });
    if (saleAlreadyRecorded(row.marketplace, row.orderId)) {
      return send(res, 200, { ok: true, duplicate: true, orderId: row.orderId, note: 'already recorded — skipped (idempotent)' });
    }
    fs.appendFileSync(LEDGER, JSON.stringify({ ...row, recordedAt: new Date().toISOString(), via: 'webhook' }) + '\n');
    return send(res, 200, { ok: true, row });
  }

  if (!checkAuth(req)) return unauthorized(res);

  if (p === '/api/data') return send(res, 200, buildSnapshot());

  // Record a sale (manual entry now; a marketplace webhook can POST the same shape later)
  if (p === '/api/sales' && req.method === 'POST') {
    const b = await readBody(req);
    if (!b.marketplace || (b.gross == null && b.units == null)) {
      return send(res, 400, { ok: false, error: 'need at least { marketplace, gross }' });
    }
    const row = {
      date: b.date || new Date().toISOString(),
      marketplace: String(b.marketplace),
      title: b.title || b.design || null,
      design: b.design || b.title || null,
      units: n(b.units || 1),
      gross: n(b.gross),
      fee: n(b.fee),
      net: b.net != null ? n(b.net) : (n(b.gross) - n(b.fee)),
      currency: b.currency || 'USD',
      orderId: b.orderId || null,
      recordedAt: new Date().toISOString(),
    };
    fs.appendFileSync(LEDGER, JSON.stringify(row) + '\n');
    return send(res, 200, { ok: true, row });
  }

  // static
  let file = p === '/' ? '/index.html' : p;
  file = path.normalize(file).replace(/^(\.\.[/\\])+/, '');
  const abs = path.join(ROOT, 'public', file);
  if (abs.startsWith(path.join(ROOT, 'public')) && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
    return send(res, 200, fs.readFileSync(abs), MIME[path.extname(abs)] || 'application/octet-stream');
  }
  return send(res, 404, { error: 'not found' });
});

server.listen(PORT, () => console.log(`WPB Sales & Products dashboard on http://localhost:${PORT}  (auth ${AUTH_USER}:****)`));