← back to Koroseal Quote Only

backend/quote-request-handler.js

142 lines

/**
 * quote-request-handler.js — generalized DW quote-request backend
 * ---------------------------------------------------------------------------
 * Reuses the commercial-quote (Koroseal) George-email pattern, generalized so
 * any quote-only line (Majilite, future lines) can POST to ONE endpoint:
 *
 *   POST /api/quote-request
 *   body: { line, productId, productTitle, productUrl, customerEmail, name,
 *           projectName, city, yards, timeline, budget, notes }
 *
 * Emails info@designerwallcoverings.com via George (Mac2 email service) with a
 * dealer-discount badge, mirroring the Koroseal handler. Falls back to a local
 * quotes.jsonl append if George is unreachable (graceful degradation).
 *
 * DEPLOY (Steve-gated): drop into the existing Koroseal quote API project on
 * Kamatera (/root/Projects/koroseal-quote-api) as an added route, or run as its
 * own pm2 process behind the same nginx /api proxy. The Liquid snippet posts to
 * https://api.designerwallcoverings.com/api/quote-request. Keep the legacy
 * /api/koroseal-quote route alive for the existing Koroseal section.
 *
 * George endpoint + INFO_EMAIL come from env (see .env.example). Nothing here
 * writes to Shopify or the catalog.
 */
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

// George runs LOCALLY on Kamatera at :9850 behind Basic auth. The Tailscale IP
// (100.x) is unreachable from Kamatera, so use the local /api/send contract that
// the working fleet callers use (astek vendor-requests, launchd reminders). TK-11106.
const GEORGE_SEND_URL = process.env.GEORGE_SEND_URL || 'http://127.0.0.1:9850/api/send';
// Basic auth for the LOCAL Kamatera George: prefer a full user:pass override,
// else build admin:<GEORGE_BASIC_AUTH_PASS> from the box's own .env (that pass is
// this box's George credential; the Mac2 'DWSecure2024!' is a different George).
const GEORGE_AUTH = process.env.GEORGE_BASIC_AUTH
  || ('admin:' + (process.env.GEORGE_BASIC_AUTH_PASS || 'DWSecure2024!'));
const GEORGE_FROM = process.env.GEORGE_FROM || 'steve@designerwallcoverings.com';
const INFO_EMAIL = process.env.INFO_EMAIL || 'info@designerwallcoverings.com';
const DEALER_DISCOUNT = process.env.DEALER_DISCOUNT || '15% Designer / Trade Discount';
const QUOTES_LOG = path.join(__dirname, 'quotes.jsonl');

function esc(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

function reqId(line) {
  const prefix = (line || 'DW').toString().slice(0, 3).toUpperCase().replace(/[^A-Z]/g, '') || 'DWQ';
  const d = new Date();
  const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
  return `${prefix}-${ymd}-${crypto.randomBytes(3).toString('hex')}`;
}

function buildHtml(b, id) {
  return `<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:600px;margin:0 auto">
    <div style="background:#1f2937;color:#fff;padding:20px 24px;border-radius:8px 8px 0 0">
      <h2 style="margin:0;font-size:18px">New Quote Request — ${esc(b.line || 'DW')}</h2>
      <p style="margin:6px 0 0;font-size:13px;color:#cbd5e1">Reference: ${esc(id)}</p>
    </div>
    <div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 8px 8px">
      <div style="display:inline-block;background:#f0fdf4;border:1px solid #86efac;color:#166534;
        padding:6px 12px;border-radius:6px;font-weight:600;font-size:13px;margin-bottom:16px">
        ✓ ${esc(DEALER_DISCOUNT)}
      </div>
      <table style="width:100%;border-collapse:collapse;font-size:14px">
        <tr><td style="padding:6px 0;color:#6b7280;width:150px">Product</td><td style="padding:6px 0"><a href="${esc(b.productUrl)}">${esc(b.productTitle)}</a></td></tr>
        <tr><td style="padding:6px 0;color:#6b7280">Product ID</td><td style="padding:6px 0">${esc(b.productId)}</td></tr>
        <tr><td style="padding:6px 0;color:#6b7280">Contact</td><td style="padding:6px 0">${esc(b.name || '(from account)')} &lt;${esc(b.customerEmail || 'n/a')}&gt;</td></tr>
        <tr><td style="padding:6px 0;color:#6b7280">Project</td><td style="padding:6px 0">${esc(b.projectName)}</td></tr>
        <tr><td style="padding:6px 0;color:#6b7280">City / Location</td><td style="padding:6px 0">${esc(b.city)}</td></tr>
        <tr><td style="padding:6px 0;color:#6b7280">Yards Required</td><td style="padding:6px 0">${esc(b.yards)}</td></tr>
        <tr><td style="padding:6px 0;color:#6b7280">Timeline</td><td style="padding:6px 0">${esc(b.timeline || '—')}</td></tr>
        <tr><td style="padding:6px 0;color:#6b7280">Budget</td><td style="padding:6px 0">${esc(b.budget || '—')}</td></tr>
        <tr><td style="padding:6px 0;color:#6b7280;vertical-align:top">Notes</td><td style="padding:6px 0">${esc(b.notes || '—')}</td></tr>
      </table>
    </div>
  </div>`;
}

function validate(b) {
  const errs = [];
  if (!b.projectName || !String(b.projectName).trim()) errs.push('projectName required');
  if (!b.city || !String(b.city).trim()) errs.push('city required');
  if (!(Number(b.yards) > 0)) errs.push('yards must be a positive number');
  // For a logged-out shopper the snippet collects name+email; require an email path.
  if (!b.customerEmail && !b.email) errs.push('email required');
  return errs;
}

async function handleQuoteRequest(req, res) {
  try {
    const b = req.body || {};
    const errs = validate(b);
    if (errs.length) return res.status(400).json({ success: false, message: errs.join('; ') });

    b.customerEmail = b.customerEmail || b.email;
    const id = reqId(b.line);
    const subject = `[${(b.line || 'DW').toUpperCase()} QUOTE REQUEST] ${b.projectName}`;
    const html = buildHtml(b, id);
    const text = `New quote request (${id})\nLine: ${b.line}\nProduct: ${b.productTitle} (${b.productId})\n`
      + `Contact: ${b.name || '(account)'} <${b.customerEmail}>\nProject: ${b.projectName}\nCity: ${b.city}\n`
      + `Yards: ${b.yards}\nTimeline: ${b.timeline || '-'}\nBudget: ${b.budget || '-'}\nNotes: ${b.notes || '-'}\n`
      + `Discount reminder: ${DEALER_DISCOUNT}`;

    let delivered = false;
    try {
      const gr = await fetch(GEORGE_SEND_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Basic ' + Buffer.from(GEORGE_AUTH).toString('base64'),
        },
        // Mirror the working fleet caller shape (astek vendor-requests): `account`
        // selects the sending mailbox, `body` carries the HTML. info@ is the
        // internal DW inbox (allowlisted) so no external-send token is required.
        body: JSON.stringify({ account: 'steve-office', from: GEORGE_FROM, to: INFO_EMAIL,
          subject, body: html, html, text, replyTo: b.customerEmail,
          message_class: 'transactional', source: 'quote-request' }),
      });
      delivered = gr.ok;
    } catch (e) {
      delivered = false;
    }

    // Always persist a local record (audit + George-down replay).
    try {
      fs.appendFileSync(QUOTES_LOG, JSON.stringify({ id, at: new Date().toISOString(), delivered, ...b }) + '\n');
    } catch (_) {}

    return res.json({
      success: true,
      requestId: id,
      message: 'Quote request received. We\'ll contact you within one business day.',
    });
  } catch (e) {
    return res.status(500).json({ success: false, message: 'Server error. Please email info@designerwallcoverings.com.' });
  }
}

module.exports = { handleQuoteRequest };