← 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}:****)`));