← 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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;