← 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, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
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)')} <${esc(b.customerEmail || 'n/a')}></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 };