← back to Dw Signup Fulfillment
server.js
205 lines
'use strict';
// DW signup / sample / rep fulfillment service.
//
// Fixes the three broken flows:
// 1. Retail signup -> emailed 3-free-samples gift code (webhook customers/create)
// 2. Trade application -> moderated approval -> `trade` tag + rep assignment
// 3. Rep round-robin with persisted cursor
//
// EVERYTHING runs in DRY_RUN by default (config.DRY_RUN). No live Shopify writes,
// no real emails, no webhook registration until Steve flips DRY_RUN=0 at go-live.
const express = require('express');
const config = require('./lib/config');
const webhook = require('./lib/webhook');
const retailCode = require('./lib/retail-code'); // WIRED default: function-backed unique sample code
const giftcard = require('./lib/giftcard'); // alternate (not wired)
const giftcodeDiscount = require('./lib/giftcode-discount'); // alternate (not wired)
const trade = require('./lib/trade');
const reps = require('./lib/reps');
const app = express();
// --- /healthz FIRST, before any auth/body parsing (Steve's fleet rule) ---
app.get('/healthz', (_req, res) => res.status(200).json({ ok: true, service: 'dw-signup-fulfillment', dry_run: config.DRY_RUN }));
// --- Favicon: 204 so browsers/tools never log a favicon 404. ---
app.get('/favicon.ico', (_req, res) => res.status(204).end());
// --- Root status page (open) — a clean 200 landing so the base URL is never a bare 404. ---
app.get('/', (_req, res) => {
res.status(200).type('html').send(`<!doctype html><html><head><meta charset="utf-8"><title>DW Signup Fulfillment</title>
<style>body{font:15px/1.6 -apple-system,system-ui,sans-serif;margin:40px;color:#1a1a1a;background:#faf9f7}code{background:#eee;padding:1px 5px;border-radius:4px}.p{display:inline-block;padding:2px 8px;border-radius:4px;background:${config.DRY_RUN ? '#fde68a' : '#bbf7d0'};font-size:12px}</style>
</head><body>
<h1>DW Signup Fulfillment <span class="p">DRY_RUN: ${config.DRY_RUN ? 'ON' : 'OFF (LIVE)'}</span></h1>
<p>Service is running. It emails new customers a 3-free-samples gift code and handles trade applications.</p>
<ul>
<li><code>GET /healthz</code> — liveness (open)</li>
<li><code>POST /webhooks/customers/create</code> — Shopify webhook (HMAC-verified)</li>
<li><code>POST /trade/apply</code> — trade application intake</li>
<li><code>GET /admin/trade</code> — trade review (basic-auth)</li>
</ul>
</body></html>`);
});
// --- Webhook receiver needs the RAW body for HMAC. Mount raw parser on that path
// ONLY, before the global JSON parser. ---
app.post('/webhooks/customers/create',
express.raw({ type: '*/*', limit: '2mb' }),
async (req, res) => {
const hmac = req.get('X-Shopify-Hmac-Sha256');
const raw = req.body; // Buffer
if (!webhook.verify(raw, hmac)) {
console.warn('[webhook] HMAC verification FAILED');
return res.status(401).json({ ok: false, error: 'hmac_invalid' });
}
let customer;
try { customer = JSON.parse(raw.toString('utf8')); }
catch { return res.status(400).json({ ok: false, error: 'bad_json' }); }
// Respond fast (Shopify wants a quick 200); do the work then log.
res.status(200).json({ ok: true, received: true });
try {
console.log(`[webhook] customers/create id=${customer.id} email=${customer.email}`);
// WIRED retail default: email the new customer a unique gift-card code for 3 free samples.
const result = await giftcard.issueRetailGiftCode(customer);
console.log('[webhook] retail gift-card result:', JSON.stringify(result));
} catch (e) {
console.error('[webhook] fulfillment error:', e.message);
}
});
// Global JSON parser for the rest.
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
// --- CORS for the public trade-apply endpoint. The modal fetch()es this from the
// Shopify storefront origin (a different host), so allow the DW storefront(s). ---
const TRADE_ALLOWED_ORIGINS = (process.env.TRADE_ALLOWED_ORIGINS ||
'https://www.designerwallcoverings.com,https://designerwallcoverings.com').split(',').map(s => s.trim());
function tradeCors(req, res, next) {
const origin = req.get('Origin');
if (origin && TRADE_ALLOWED_ORIGINS.includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
res.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
}
if (req.method === 'OPTIONS') return res.status(204).end();
next();
}
app.options('/trade/apply', tradeCors);
// --- Trade application intake (public) ---
app.post('/trade/apply', tradeCors, (req, res) => {
const b = req.body || {};
if (!b.email) return res.status(400).json({ ok: false, error: 'email required' });
const created = trade.apply(b);
res.json({ ok: true, id: created.id, status: created.status, created_at: created.created_at });
});
// --- Basic-auth guard for /admin/* ---
function adminAuth(req, res, next) {
const hdr = req.get('Authorization') || '';
const [scheme, val] = hdr.split(' ');
if (scheme === 'Basic' && val) {
const [u, p] = Buffer.from(val, 'base64').toString('utf8').split(':');
if (u === config.ADMIN_USER && p === config.ADMIN_PASS) return next();
}
res.set('WWW-Authenticate', 'Basic realm="DW Trade Admin"');
return res.status(401).send('Auth required');
}
// --- Admin trade review surface ---
app.get('/admin/trade', adminAuth, (_req, res) => {
const pending = trade.listPending();
res.type('html').send(renderAdmin(pending));
});
app.post('/admin/trade/:id/approve', adminAuth, async (req, res) => {
const result = await trade.approve(req.params.id);
res.status(result.ok ? 200 : 400).json(result);
});
app.post('/admin/trade/:id/reject', adminAuth, async (req, res) => {
const result = await trade.reject(req.params.id);
res.status(result.ok ? 200 : 400).json(result);
});
// --- Introspection helpers ---
app.get('/reps/next', adminAuth, (_req, res) => res.json({ assigned: reps.houseAccount() }));
// Manual retail trigger (admin) — handy for go-live smoke test without a real
// webhook. Default is the WIRED gift-card path. ALTERNATES for comparison only:
// ?mode=sharedcode (function shared code) and ?mode=discount (collection code).
app.post('/admin/retail/issue', adminAuth, async (req, res) => {
const customer = req.body || {};
if (!customer.email) return res.status(400).json({ ok: false, error: 'email required' });
let mode = req.query.mode || 'giftcard';
let result;
if (mode === 'sharedcode') result = await retailCode.issueRetailCode(customer); // alternate
else if (mode === 'discount') result = await giftcodeDiscount.issueRetailDiscountCode(customer); // alternate
else { mode = 'giftcard'; result = await giftcard.issueRetailGiftCode(customer); } // WIRED default
res.json({ ok: true, mode, result });
});
// --- Admin HTML with created date+time chip (Steve's admin-card rule) ---
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
} catch { return iso; }
}
function renderAdmin(pending) {
const cards = pending.map(a => `
<div class="card">
<div class="when" title="${esc(a.created_at)}">🕓 ${esc(fmtDate(a.created_at))}</div>
<div class="biz">${esc(a.business_name || '(no business name)')}</div>
<div class="meta">${esc(a.email)}${a.phone ? ' · ' + esc(a.phone) : ''}</div>
<div class="meta">Resale cert: ${esc(a.resale_cert || '—')}</div>
<div class="id">${esc(a.id)}</div>
<div class="actions">
<button onclick="act('${a.id}','approve')">Approve</button>
<button class="rej" onclick="act('${a.id}','reject')">Reject</button>
</div>
</div>`).join('\n');
return `<!doctype html><html><head><meta charset="utf-8"><title>DW Trade Admin</title>
<style>
body{font:14px/1.5 -apple-system,system-ui,sans-serif;margin:24px;color:#1a1a1a;background:#faf9f7}
h1{font-weight:600}
.dry{display:inline-block;padding:2px 8px;border-radius:4px;background:${config.DRY_RUN ? '#fde68a' : '#bbf7d0'};font-size:12px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-top:16px}
.card{border:1px solid #e5e2dd;border-radius:8px;padding:12px;background:#fff}
.when{font-size:12px;color:#6b7280}
.biz{font-size:16px;font-weight:600;margin:4px 0}
.meta{font-size:13px;color:#374151}
.id{font-size:11px;color:#9ca3af;margin-top:6px;font-family:monospace}
.actions{margin-top:10px;display:flex;gap:8px}
button{padding:6px 12px;border:1px solid #111;background:#111;color:#fff;border-radius:6px;cursor:pointer}
button.rej{background:#fff;color:#b91c1c;border-color:#b91c1c}
.empty{color:#6b7280;margin-top:20px}
</style></head><body>
<h1>Trade applications <span class="dry">DRY_RUN: ${config.DRY_RUN ? 'ON (no live writes)' : 'OFF (LIVE)'}</span></h1>
<div class="grid">${cards || '<p class="empty">No pending applications.</p>'}</div>
<script>
async function act(id, decision){
if(!confirm(decision.toUpperCase()+' '+id+'?')) return;
const r = await fetch('/admin/trade/'+id+'/'+decision, {method:'POST'});
const j = await r.json();
alert(JSON.stringify(j, null, 2));
location.reload();
}
</script></body></html>`;
}
if (require.main === module) {
app.listen(config.PORT, () => {
console.log(`[dw-signup-fulfillment] listening on :${config.PORT} DRY_RUN=${config.DRY_RUN}`);
console.log(` health: http://127.0.0.1:${config.PORT}/healthz`);
console.log(` admin: http://127.0.0.1:${config.PORT}/admin/trade (basic-auth ${config.ADMIN_USER}/${config.ADMIN_PASS})`);
console.log(` webhook: POST /webhooks/customers/create (HMAC-verified)`);
if (config.DRY_RUN) console.log(' ** DRY_RUN ON — no live Shopify writes, no real emails, nothing registered. **');
});
}
module.exports = app;