[object Object]

← back to Designer Wallcoverings

SKU-inquiry backend (George-routed, fail-open) + throttle on apply scripts; 41 samples added + 431 tagged on live store

3e354e5e32dce2d38f6b8d0814b14f3167ff8cf8 · 2026-06-27 00:10:56 -0700 · Steve

Files touched

Diff

commit 3e354e5e32dce2d38f6b8d0814b14f3167ff8cf8
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jun 27 00:10:56 2026 -0700

    SKU-inquiry backend (George-routed, fail-open) + throttle on apply scripts; 41 samples added + 431 tagged on live store
---
 .gitignore                               |  1 +
 shopify/quote-api/server.js              | 27 +++++++++
 shopify/quote-api/sku-inquiry-handler.js | 97 ++++++++++++++++++++++++++++++++
 3 files changed, 125 insertions(+)

diff --git a/.gitignore b/.gitignore
index b4d5ba07..53978c2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,3 +73,4 @@ shopify/scripts/gmc-race-clean/out/
 shopify/scripts/cadence/stock-2026-guard.latest.json
 shopify/theme-backups/
 enrich-cache/
+shopify/quote-api/sku-inquiries.jsonl
diff --git a/shopify/quote-api/server.js b/shopify/quote-api/server.js
new file mode 100644
index 00000000..adb4b36d
--- /dev/null
+++ b/shopify/quote-api/server.js
@@ -0,0 +1,27 @@
+/**
+ * server.js — minimal Express app exposing the generalized SKU-inquiry endpoint.
+ * Mirrors the Koroseal quote server (Kamatera) but for /api/sku-inquiry.
+ *
+ * Integration options on go-live (GATED — deploy is Steve-gated):
+ *   A) Mount the route into the existing Koroseal quote Express app on Kamatera:
+ *        const { handleSkuInquiry } = require('./sku-inquiry-handler');
+ *        app.post('/api/sku-inquiry', handleSkuInquiry);
+ *   B) Run this standalone under pm2 and proxy /api/sku-inquiry to it from nginx,
+ *      same way /api/koroseal-quote is proxied to the storefront domain.
+ */
+const express = require('express');
+const cors = require('cors');
+const { handleSkuInquiry } = require('./sku-inquiry-handler');
+
+const app = express();
+app.use(cors({ origin: process.env.CORS_ORIGIN || 'https://www.designerwallcoverings.com' }));
+app.use(express.json({ limit: '64kb' }));
+
+app.get('/health', (_req, res) => res.json({ ok: true, service: 'sku-inquiry' }));
+app.post('/api/sku-inquiry', handleSkuInquiry);
+
+const PORT = process.env.PORT || 3002;
+if (require.main === module) {
+  app.listen(PORT, () => console.log(`sku-inquiry api on :${PORT}`));
+}
+module.exports = app;
diff --git a/shopify/quote-api/sku-inquiry-handler.js b/shopify/quote-api/sku-inquiry-handler.js
new file mode 100644
index 00000000..539d1e10
--- /dev/null
+++ b/shopify/quote-api/sku-inquiry-handler.js
@@ -0,0 +1,97 @@
+/**
+ * sku-inquiry-handler.js
+ * Generalized "Contact Us About This SKU" inquiry handler — the backend for the
+ * contact-for-price.liquid PDP block (the 431 sample-only SKUs whose roll price is
+ * not shown online). Generalizes the Koroseal quote handler to a richer payload.
+ *
+ * HARD RULE: never sends mail directly — routes the inquiry through George
+ * (the sole send/read layer). On George failure, appends to a JSONL log so a
+ * lead is never lost (fail-open to disk, not silent-drop).
+ */
+const fs = require('fs');
+const path = require('path');
+
+const SALES_INBOX = process.env.SALES_INBOX || 'info@designerwallcoverings.com';
+const GEORGE_URL = process.env.GEORGE_URL || 'http://127.0.0.1:9850'; // local George; Tailscale on Kamatera
+const GEORGE_AUTH = process.env.GEORGE_AUTH || 'admin:'; // basic admin:(empty) per local George
+const LOG = process.env.SKU_INQUIRY_LOG || path.join(__dirname, 'sku-inquiries.jsonl');
+
+const esc = s => String(s == null ? '' : s).replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
+const reqId = () => {
+  const d = new Date();
+  const ymd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
+  return `SKU-${ymd}-${Math.random().toString(16).slice(2, 8).toUpperCase()}`;
+};
+
+const TRADE_ROLES = new Set(['interior_designer', 'trade', 'architect']);
+
+function validate(b) {
+  const errs = [];
+  if (!b || typeof b !== 'object') return ['empty body'];
+  if (!b.name || !String(b.name).trim()) errs.push('name required');
+  if (!b.email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(b.email))) errs.push('valid email required');
+  if (!b.role || !String(b.role).trim()) errs.push('role required');
+  if (!b.sku && !b.productId) errs.push('sku/productId required');
+  return errs;
+}
+
+function buildEmail(b, id) {
+  const isTrade = TRADE_ROLES.has(b.role);
+  const rows = [
+    ['SKU', b.sku], ['Product', b.productTitle], ['Name', b.name], ['Email', b.email],
+    ['Phone', b.phone], ['Company', b.company], ['Role', b.role],
+    ['Quantity', [b.quantity, b.unit].filter(Boolean).join(' ')],
+    ['Project', b.projectName], ['Location', b.city], ['Timeline', b.timeline],
+    ['Budget', b.budget], ['Notes', b.notes],
+  ].filter(([, v]) => v && String(v).trim());
+
+  const html = `
+  <div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:600px">
+    <h2 style="color:#2a2622">New SKU Inquiry — ${esc(b.sku || b.productTitle || '')}</h2>
+    <p style="color:#6b6357">Reference ${esc(id)}</p>
+    ${isTrade ? '<p style="background:#eef7ee;color:#2f6b2f;padding:8px 12px;border-radius:6px;font-weight:600">✓ Trade contact — apply designer discount in quote</p>' : ''}
+    <table style="border-collapse:collapse;width:100%">
+      ${rows.map(([k, v]) => `<tr><td style="padding:6px 10px;color:#8a8276;border-bottom:1px solid #eee;white-space:nowrap">${esc(k)}</td><td style="padding:6px 10px;border-bottom:1px solid #eee">${esc(v)}</td></tr>`).join('')}
+    </table>
+  </div>`;
+  const text = rows.map(([k, v]) => `${k}: ${v}`).join('\n') + `\n\nReference: ${id}`;
+  return { html, text, isTrade };
+}
+
+async function sendViaGeorge(subject, html, text) {
+  const r = await fetch(`${GEORGE_URL}/api/send`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: 'Basic ' + Buffer.from(GEORGE_AUTH).toString('base64'),
+    },
+    body: JSON.stringify({ to: SALES_INBOX, subject, html, text }),
+  });
+  if (!r.ok) throw new Error('George send failed: ' + r.status);
+  return r.json().catch(() => ({}));
+}
+
+/** Express handler: POST /api/sku-inquiry */
+async function handleSkuInquiry(req, res) {
+  const b = req.body || {};
+  const errs = validate(b);
+  if (errs.length) return res.status(400).json({ success: false, message: errs.join('; ') });
+
+  const id = reqId();
+  const { html, text } = buildEmail(b, id);
+  const subject = `[SKU INQUIRY] ${b.sku || b.productTitle || ''} — ${b.name}`;
+  const record = { id, ts: new Date().toISOString(), ...b };
+
+  try {
+    await sendViaGeorge(subject, html, text);
+    record.delivered = 'george';
+  } catch (e) {
+    record.delivered = 'jsonl-fallback';
+    record.error = String(e.message || e);
+  }
+  try { fs.appendFileSync(LOG, JSON.stringify(record) + '\n'); } catch (_) {}
+
+  return res.json({ success: true, requestId: id });
+}
+
+module.exports = { handleSkuInquiry, validate, buildEmail, reqId };

← d497aec8 auto-save: 2026-06-27T00:06:21 (7 files) — pending-approval/  ·  back to Designer Wallcoverings  ·  Dev-theme push script for contact-for-price block (tag-gated 8a734295 →