← back to Estimate Instant

server.js

431 lines

// estimate-instant — wallpaper "how many rolls + instant price" calculator.
// Zero-dep Node http. Serves the calculator, computes rolls-needed server-side
// (pattern-repeat + waste math), and captures leads to data/leads.json.
//
// BASIC_AUTH gates the /admin page + /api/leads (lead data); the estimator
// itself (/  /api/rolls  /api/estimate  /api/lead) is PUBLIC — it must be
// embeddable on 200+ sister sites without auth friction.
//
// Run: PORT=3901 BASIC_AUTH=admin:DW2024! node server.js
//
// Shopify integration (read-only, no token required):
//   • data/shopify-catalog.json — static pull from LIVE DW Shopify store (200 products).
//     Contains variant_id + price per SKU and checkout_domain for cart permalinks.
//   • Cart permalink: https://<checkout_domain>/cart/<variant_id>:<qty>
//   • data/rolls.json rolls are enriched with shopify_variant_id + shopify_price.
//     shopify_match=true means the roll SKU matched a catalog SKU exactly.
//     shopify_match=false means a clearly-labeled stand-in variant is used (see _note fields).
//   • NO Shopify token lives in this server — only public catalog data + public cart URLs.
//   • PRODUCTION TODO: roll_width_in / roll_length_ft / pattern_repeat_in should be pulled
//     from Shopify metafields keyed by variant_id (not stored here). The _note field on each
//     rolls.json entry documents this seam.
//
// Swap real DW data: see scripts/pull-rollspecs.sql — one psql command
// rewrites data/rolls.json with live products from dw_unified.
//
// House rules honored:
//   • admin cards show created date+time (🕓 toLocaleString format, ISO in title=)
//   • admin grid has sort <select> + density <input type=range>, persisted to localStorage
//   • gitified; commit after each working step (author steve@designerwallcoverings.com)

const http = require('http'), fs = require('fs'), path = require('path');
const PORT = parseInt(process.env.PORT || '3900', 10);
const BASIC_AUTH = process.env.BASIC_AUTH || ''; // optional override for the admin credential
// Admin surfaces (/admin + /api/leads = captured lead PII) are ALWAYS gated — even when the
// public calculator runs open on an embed host. Default admin/DW2024!; override via env.
const ADMIN_CRED = process.env.BASIC_AUTH || process.env.ADMIN_AUTH || 'admin:DW2024!';
const DIR = __dirname;
const ROLLS = path.join(DIR, 'data', 'rolls.json');
const LEADS = path.join(DIR, 'data', 'leads.json');
const CATALOG_PATH = path.join(DIR, 'data', 'shopify-catalog.json');
const TRIM_ALLOWANCE_IN = 4; // 2" trim top + bottom per strip (trade standard)
const MAX_JSON_BODY_BYTES = 16 * 1024;

function validateRollSnapshot(rolls) {
  const errors = [];
  if (!Array.isArray(rolls) || rolls.length === 0) return { ok: false, errors: ['snapshot must be a non-empty array'] };
  const canonical = new Set(), matchedAliases = new Set();
  rolls.forEach((roll, index) => {
    const at = `roll[${index}]`;
    if (!roll || typeof roll !== 'object' || Array.isArray(roll)) { errors.push(`${at} must be an object`); return; }
    const rawSku = typeof roll.sku === 'string' ? roll.sku : '';
    const sku = rawSku.trim().toUpperCase();
    if (!sku) errors.push(`${at}.sku is required`);
    else if (rawSku !== sku) errors.push(`${at}.sku must be canonical uppercase without surrounding whitespace`);
    else if (canonical.has(sku)) errors.push(`${at}.sku duplicates ${sku}`);
    else canonical.add(sku);
    if (typeof roll.roll_width_in !== 'number' || !Number.isFinite(roll.roll_width_in) || roll.roll_width_in <= 0) errors.push(`${at}.roll_width_in is invalid`);
    if (typeof roll.roll_length_ft !== 'number' || !Number.isFinite(roll.roll_length_ft) || roll.roll_length_ft <= 0) errors.push(`${at}.roll_length_ft is invalid`);
    if (typeof roll.pattern_repeat_in !== 'number' || !Number.isFinite(roll.pattern_repeat_in) || roll.pattern_repeat_in < 0) errors.push(`${at}.pattern_repeat_in is invalid`);
    const match = typeof roll.match === 'string' ? roll.match : '';
    if (!['random', 'straight', 'half-drop'].includes(match)) errors.push(`${at}.match is invalid or noncanonical`);
    if (typeof roll.shopify_match !== 'boolean') errors.push(`${at}.shopify_match must be boolean`);
    if (roll.shopify_match === true) {
      const rawAlias = typeof roll.shopify_sku === 'string' ? roll.shopify_sku : '';
      const alias = rawAlias.trim().toUpperCase();
      if (!alias) errors.push(`${at}.shopify_sku is required for an exact match`);
      else if (rawAlias !== alias) errors.push(`${at}.shopify_sku must be canonical uppercase without surrounding whitespace`);
      else if (matchedAliases.has(alias)) errors.push(`${at}.shopify_sku duplicates ${alias}`);
      else matchedAliases.add(alias);
      if (typeof roll.shopify_price !== 'number' || !Number.isFinite(roll.shopify_price) || roll.shopify_price <= 0) errors.push(`${at}.shopify_price is invalid`);
    }
  });
  return { ok: errors.length === 0, errors };
}

function loadRolls(file = ROLLS) {
  let rolls;
  try { rolls = JSON.parse(fs.readFileSync(file, 'utf8')); }
  catch (error) { throw new Error(`roll snapshot unreadable: ${error.message}`); }
  const validation = validateRollSnapshot(rolls);
  if (!validation.ok) throw new Error(`roll snapshot invalid: ${validation.errors.slice(0, 5).join('; ')}`);
  return rolls;
}
function loadLeads() { try { return JSON.parse(fs.readFileSync(LEADS, 'utf8')); } catch { return []; } }

// Load Shopify catalog (static, read-only — no token needed).
// Returns { checkout_domain, products_by_sku } for cart permalink construction.
function loadCatalog() {
  try {
    const c = JSON.parse(fs.readFileSync(CATALOG_PATH, 'utf8'));
    const bySkuUpper = {};
    (c.products || []).forEach(p => { bySkuUpper[String(p.sku).toUpperCase()] = p; });
    return { checkout_domain: c.checkout_domain || 'www.designerwallcoverings.com', bySkuUpper };
  } catch {
    return { checkout_domain: 'www.designerwallcoverings.com', bySkuUpper: {} };
  }
}

function authed(req) {
  const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
  if (!m) return false;
  try { return Buffer.from(m[1], 'base64').toString() === ADMIN_CRED; } catch { return false; }
}

// The core: rolls-needed with pattern-repeat + waste math (trade-standard strip method).
//
// For a STRAIGHT match: each strip cut length = ceil(wall_height + trim / repeat) × repeat
// For a HALF-DROP match: alternating strips start a half-repeat lower, consuming repeat/2
//   of additional roll length per roll to establish the offset — so usable length is reduced.
// strips_per_roll = floor(usable_roll_length / cut_length)
// strips_needed   = ceil(wall_width / roll_width)
// rolls_needed    = ceil(strips_needed / strips_per_roll)
//
// Shopify checkout: when roll has shopify_variant_id, we build the cart permalink
//   https://<checkout_domain>/cart/<variant_id>:<rolls_needed>
// shopify_match=false means the variant is a stand-in (prototype SKU not in catalog).
function estimate({ wallWidthIn, wallHeightIn, roll, checkout_domain }) {
  const errs = [];
  const W = Number(wallWidthIn), H = Number(wallHeightIn);
  if (!(W > 0)) errs.push('Enter a wall width greater than 0.');
  if (!(H > 0)) errs.push('Enter a wall height greater than 0.');
  if (!roll) errs.push('Pick a pattern or select a roll.');
  if (errs.length) return { ok: false, errors: errs };

  const rollWidth = Number(roll.roll_width_in);
  const rollLenIn = Number(roll.roll_length_ft) * 12;
  const repeat = Number(roll.pattern_repeat_in) || 0;

  // Each strip must clear wall height + trim, rounded UP to a full pattern repeat so
  // strips align side-to-side. Half-drop match: adjacent strips are offset by half a
  // repeat, so each strip needs an extra half-repeat of length to land at either offset
  // — added BEFORE the round-up. Conservative by design: it rounds up and never
  // under-counts (an under-count leaves the installer short mid-wall).
  const isHalfDrop = String(roll.match || '').toLowerCase() === 'half-drop' && repeat > 0;
  const rawCut = H + TRIM_ALLOWANCE_IN + (isHalfDrop ? repeat / 2 : 0);
  const cutLen = repeat > 0 ? Math.ceil(rawCut / repeat) * repeat : rawCut;
  const stripsPerRoll = Math.floor(rollLenIn / cutLen);

  if (stripsPerRoll < 1) {
    return {
      ok: false,
      errors: [`Wall is too tall for this roll — one drop (${cutLen}") exceeds the ${rollLenIn}" roll length. Pick a longer roll or split the wall.`]
    };
  }

  const totalStrips = Math.ceil(W / rollWidth);
  const rollsNeeded = Math.ceil(totalStrips / stripsPerRoll);

  // Price: use the real Shopify price if available (shopify_price), else fall back to
  // the local price_per_roll placeholder.
  const pricePerRoll = Number(roll.shopify_price || roll.price_per_roll);
  const price = +(rollsNeeded * pricePerRoll).toFixed(2);

  // True material waste: length actually landing on the wall vs total length bought
  // (captures roll-end offcuts + per-strip trim + pattern-repeat allowance).
  const usedIn = totalStrips * H;
  const boughtIn = rollsNeeded * rollLenIn;
  const wastePct = boughtIn > 0 ? Math.round((1 - usedIn / boughtIn) * 100) : 0;

  // Shopify hosted checkout cart permalink — no API key needed, public URL.
  // Format: https://<checkout_domain>/cart/<variant_id>:<qty>
  // shopify_match=false means stand-in variant (prototype SKU not in live catalog).
  const variantId = roll.shopify_variant_id || null;
  const domain = checkout_domain || 'www.designerwallcoverings.com';
  const cartUrl = variantId
    ? `https://${domain}/cart/${variantId}:${rollsNeeded}`
    : null;
  const shopifyMatch = roll.shopify_match !== undefined ? roll.shopify_match : false;

  return {
    ok: true,
    rollsNeeded, totalStrips, stripsPerRoll,
    cutLengthIn: +cutLen.toFixed(1),
    pricePerRoll,
    price, wastePct,
    match: roll.match, sku: roll.sku, pattern: roll.pattern,
    // Shopify checkout fields — used by the frontend CTA
    shopify_variant_id: variantId,
    shopify_sku: roll.shopify_sku || null,
    shopify_match: shopifyMatch,
    cart_url: cartUrl,
    checkout_domain: domain
  };
}

// Stable API contract for room-based consumers. This deliberately uses the
// checked-in roll-spec snapshot: the dw_unified mirror is not authoritative for
// sell price, and this local endpoint must never imply a live database read.
function calculateCoverage(input, rolls = loadRolls(), checkout_domain) {
  const sku = typeof input?.sku === 'string' ? input.sku.trim() : '';
  const roomWidthFt = input?.room_width_ft;
  const roomHeightFt = input?.room_height_ft;
  const numWalls = input?.num_walls;
  const errors = [];
  if (!sku) errors.push('sku is required.');
  if (typeof roomWidthFt !== 'number' || !Number.isFinite(roomWidthFt) || roomWidthFt <= 0 || roomWidthFt > 1000) {
    errors.push('room_width_ft must be a JSON number greater than 0 and at most 1000.');
  }
  if (typeof roomHeightFt !== 'number' || !Number.isFinite(roomHeightFt) || roomHeightFt <= 0 || roomHeightFt > 100) {
    errors.push('room_height_ft must be a JSON number greater than 0 and at most 100.');
  }
  if (typeof numWalls !== 'number' || !Number.isInteger(numWalls) || numWalls < 1 || numWalls > 100) {
    errors.push('num_walls must be a JSON integer from 1 through 100.');
  }
  if (errors.length) return { ok: false, errors };

  const key = sku.toUpperCase();
  const canonicalMatches = rolls.filter((item) => String(item.sku || '').toUpperCase() === key);
  if (canonicalMatches.length > 1) {
    return { ok: false, errors: [`Ambiguous local roll specification for SKU ${sku}.`] };
  }
  const aliasMatches = canonicalMatches.length === 0
    ? rolls.filter((item) => item.shopify_match === true && String(item.shopify_sku || '').toUpperCase() === key)
    : [];
  if (aliasMatches.length > 1) {
    return { ok: false, errors: [`Ambiguous matched Shopify SKU ${sku}.`] };
  }
  const roll = canonicalMatches[0] || aliasMatches[0];
  if (!roll) return { ok: false, errors: [`No local roll specification found for SKU ${sku}.`] };

  const validPositiveSpec = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0;
  const validRepeat = typeof roll.pattern_repeat_in === 'number' &&
    Number.isFinite(roll.pattern_repeat_in) && roll.pattern_repeat_in >= 0;
  const match = String(roll.match || '').trim().toLowerCase();
  if (!validPositiveSpec(roll.roll_width_in) || !validPositiveSpec(roll.roll_length_ft) ||
      !validRepeat || !['random', 'straight', 'half-drop'].includes(match)) {
    return { ok: false, errors: ['Local roll specification is invalid; verify width, length, repeat, and match.'] };
  }

  const totalWidthIn = roomWidthFt * 12 * numWalls;
  const wallHeightIn = roomHeightFt * 12;
  if (!Number.isFinite(totalWidthIn) || Math.abs(totalWidthIn) > Number.MAX_SAFE_INTEGER || !Number.isFinite(wallHeightIn)) {
    return { ok: false, errors: ['Room dimensions exceed the safe calculation range.'] };
  }
  const result = estimate({
    wallWidthIn: totalWidthIn,
    wallHeightIn,
    roll,
    checkout_domain,
  });
  if (!result.ok) return result;
  const quoteAuthoritative = roll.shopify_match === true &&
    typeof roll.shopify_price === 'number' && Number.isFinite(roll.shopify_price) && roll.shopify_price > 0 &&
    typeof roll.roll_width_in === 'number' && Number.isFinite(roll.roll_width_in) && roll.roll_width_in > 0 &&
    typeof roll.roll_length_ft === 'number' && Number.isFinite(roll.roll_length_ft) && roll.roll_length_ft > 0 &&
    typeof roll.pattern_repeat_in === 'number' && Number.isFinite(roll.pattern_repeat_in) && roll.pattern_repeat_in >= 0;
  const numericOutputs = [result.rollsNeeded, result.wastePct,
    result.totalStrips, result.stripsPerRoll, result.cutLengthIn];
  if (quoteAuthoritative) numericOutputs.push(result.price, result.pricePerRoll);
  if (numericOutputs.some((value) => typeof value !== 'number' || !Number.isFinite(value))) {
    return { ok: false, errors: ['Roll specifications produced a non-finite calculation; verify the product data.'] };
  }
  if (![result.rollsNeeded, result.totalStrips, result.stripsPerRoll].every(Number.isSafeInteger) ||
      result.rollsNeeded < 1 || result.totalStrips < 1 || result.stripsPerRoll < 1 || result.cutLengthIn <= 0) {
    return { ok: false, errors: ['Roll specifications produced an invalid quantity; verify the product data.'] };
  }

  const notes = [
    `Assumes ${numWalls} wall${numWalls === 1 ? '' : 's'} at ${roomWidthFt} ft wide by ${roomHeightFt} ft high.`,
    'Calculated from the checked-in local roll-spec snapshot; confirm current specifications and price before quoting.',
  ];
  if (!quoteAuthoritative) notes.push('This local record is not quote-authoritative; pricing is withheld until an exact matched product has valid price and roll specifications.');

  return {
    ok: true,
    sku: result.sku,
    requested_sku: sku,
    rolls_needed: result.rollsNeeded,
    waste_pct: result.wastePct,
    total_cost: quoteAuthoritative ? result.price : null,
    price_per_roll: quoteAuthoritative ? result.pricePerRoll : null,
    quote_authoritative: quoteAuthoritative,
    strips_needed: result.totalStrips,
    strips_per_roll: result.stripsPerRoll,
    cut_length_in: result.cutLengthIn,
    notes,
    data_source: 'local-rollspec-snapshot',
  };
}

function body(req, limit = MAX_JSON_BODY_BYTES) {
  return new Promise(resolve => {
    let bytes = 0, tooLarge = false, ended = false, settled = false;
    const chunks = [];
    const finish = (result) => {
      if (settled) return;
      settled = true;
      resolve(result);
    };
    req.on('data', chunk => {
      const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
      bytes += buffer.length;
      if (bytes > limit) { tooLarge = true; chunks.length = 0; return; }
      if (!tooLarge) chunks.push(buffer);
    });
    req.on('end', () => {
      ended = true;
      if (tooLarge) return finish({ ok: false, status: 413, error: `JSON body exceeds ${limit} bytes.` });
      try {
        const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
        finish({ ok: true, value: JSON.parse(text || '{}') });
      } catch {
        finish({ ok: false, status: 400, error: 'Malformed or invalid UTF-8 JSON body.' });
      }
    });
    req.on('aborted', () => finish({ ok: false, status: 400, error: 'Request body was aborted.' }));
    req.on('error', () => finish({ ok: false, status: 400, error: 'Unable to read request body.' }));
    req.on('close', () => {
      if (!ended) finish({ ok: false, status: 400, error: 'Request body closed before completion.' });
    });
  });
}
function json(res, code, obj) {
  res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
  res.end(JSON.stringify(obj));
}
function serveFile(res, fp, contentType) {
  res.writeHead(200, { 'Content-Type': contentType });
  res.end(fs.readFileSync(fp));
}

// Content-type map
function mime(f) {
  if (f.endsWith('.html')) return 'text/html';
  if (f.endsWith('.js'))   return 'text/javascript';
  if (f.endsWith('.css'))  return 'text/css';
  if (f.endsWith('.json')) return 'application/json';
  return 'text/plain';
}

async function handleRequest(req, res) {
  const u = new URL(req.url, 'http://x');
  const p = u.pathname;

  // CORS preflight for embedded widget cross-origin POSTs
  if (req.method === 'OPTIONS') {
    res.writeHead(204, {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type'
    });
    return res.end();
  }

  // ── PUBLIC routes (no auth — estimator must work when embedded) ─────────────
  if (p === '/api/rolls') return json(res, 200, { rolls: loadRolls() });

  // Expose checkout_domain so the frontend can build cart permalinks without
  // the server having to inject it into every page (also used by embed.js).
  if (p === '/api/catalog-meta') {
    const { checkout_domain } = loadCatalog();
    return json(res, 200, { checkout_domain });
  }

  if (p === '/api/estimate' && req.method === 'POST') {
    const parsed = await body(req);
    if (!parsed.ok) return json(res, parsed.status, { ok: false, error: parsed.error });
    const b = parsed.value;
    const roll = loadRolls().find(r => r.sku === b.sku);
    const { checkout_domain } = loadCatalog();
    const result = estimate({ wallWidthIn: b.wallWidthIn, wallHeightIn: b.wallHeightIn, roll, checkout_domain });
    return json(res, result.ok ? 200 : 400, result);
  }

  if (p === '/api/calculate-coverage' && req.method === 'POST') {
    const parsed = await body(req);
    if (!parsed.ok) return json(res, parsed.status, { ok: false, error: parsed.error });
    const input = parsed.value;
    const { checkout_domain } = loadCatalog();
    const result = calculateCoverage(input, loadRolls(), checkout_domain);
    return json(res, result.ok ? 200 : 400, result);
  }

  if (p === '/api/lead' && req.method === 'POST') {
    const parsed = await body(req);
    if (!parsed.ok) return json(res, parsed.status, { ok: false, error: parsed.error });
    const b = parsed.value;
    if (!b.email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(b.email)) {
      return json(res, 400, { ok: false, error: 'A valid email is required.' });
    }
    const leads = loadLeads();
    leads.push({
      id: leads.length + 1,
      email: String(b.email).slice(0, 200),
      quote: b.quote || null,
      created_at: new Date().toISOString()
    });
    fs.writeFileSync(LEADS, JSON.stringify(leads, null, 2));
    // STUB: real email send would go here (SendGrid / George / etc.)
    // e.g. POST to https://api.sendgrid.com/v3/mail/send with the quote summary
    return json(res, 200, { ok: true });
  }

  // ── ADMIN-GATED routes (require BASIC_AUTH if set) ──────────────────────────
  if (p === '/admin' || p === '/admin/' || p.startsWith('/api/leads')) {
    if (!authed(req)) {
      res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="estimate-instant admin"' });
      return res.end('auth required');
    }
    if (p === '/admin' || p === '/admin/') {
      const fp = path.join(DIR, 'public', 'admin.html');
      if (fs.existsSync(fp)) return serveFile(res, fp, 'text/html');
    }
    if (p === '/api/leads') return json(res, 200, { leads: loadLeads() });
  }

  // ── Static files from public/ ───────────────────────────────────────────────
  // Resolve path carefully — never let basename tricks escape public/
  let file = p === '/' ? 'index.html' : p.replace(/^\//, '');
  // Prevent path traversal: only allow simple filenames (no sub-directories except known ones)
  const safe = path.normalize(file).replace(/^(\.\.(\/|\\|$))+/, '');
  const fp = path.join(DIR, 'public', safe);
  if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
    return serveFile(res, fp, mime(file));
  }

  res.writeHead(404); res.end('not found');
}

if (require.main === module) {
  loadRolls(); // fail closed before binding if the whole checked-in snapshot is invalid
  http.createServer(handleRequest).listen(PORT, '127.0.0.1', function () {
    console.log('[estimate-instant] http://localhost:' + this.address().port);
    console.log('  Calculator: http://localhost:' + this.address().port + '/');
    console.log('  Admin:      http://localhost:' + this.address().port + '/admin  (auth required — ADMIN_CRED)');
    console.log('  Embed demo: http://localhost:' + this.address().port + '/embed-demo.html');
  });
}

module.exports = { body, calculateCoverage, estimate, handleRequest, loadRolls, validateRollSnapshot, MAX_JSON_BODY_BYTES };