[object Object]

← back to Dw Signup Fulfillment

TK: guarded auto-approve trade signups on submit (email-valid + dedupe guardrails; staff FYI; legacy card fallback)

3c7bad1708dc718f6d8cb30d365fdfdc51d965c7 · 2026-09-10 09:35:29 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017oFcLBoyTUxGQP7xnXtb9S

Files touched

Diff

commit 3c7bad1708dc718f6d8cb30d365fdfdc51d965c7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 09:35:29 2026 -0700

    TK: guarded auto-approve trade signups on submit (email-valid + dedupe guardrails; staff FYI; legacy card fallback)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017oFcLBoyTUxGQP7xnXtb9S
---
 lib/config.js | 14 ++++++++++++++
 lib/email.js  | 26 +++++++++++++++++++++++++-
 lib/trade.js  | 12 +++++++++++-
 server.js     | 59 +++++++++++++++++++++++++++++++++++++++++++++++------------
 4 files changed, 97 insertions(+), 14 deletions(-)

diff --git a/lib/config.js b/lib/config.js
index c375b0c..237128e 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -125,9 +125,23 @@ const config = {
   ADMIN_USER: process.env.ADMIN_USER || firstEnv('ADMIN_USER', SECRETS_ENVS) || 'admin',
   ADMIN_PASS: process.env.ADMIN_PASS || firstEnv('DW_SIGNUP_ADMIN_PASS', SECRETS_ENVS) || 'DW2024!',
 
+  // --- Auto-approve on signup (Steve, 2026-09-10) ---
+  // GUARDED auto-approve: every genuine new /trade/apply is approved instantly with no
+  // human review (tags the customer `trade`, assigns a rep, emails them "approved"),
+  // EXCEPT (a) blank/invalid emails and (b) an email that already has an approved app
+  // (exact-duplicate re-submit) — so a bot/competitor spraying the form can't mint
+  // unlimited trade accounts + free samples. Default ON. Flip to '0' to restore the
+  // old "park as pending + email a review card" behavior. Still DRY_RUN-safe: in
+  // DRY_RUN the approve() call only simulates (no live tag/email), like every flow here.
+  TRADE_AUTO_APPROVE: !(String(process.env.TRADE_AUTO_APPROVE || '1').toLowerCase() === '0'
+    || String(process.env.TRADE_AUTO_APPROVE || '1').toLowerCase() === 'false'),
+
   // --- Trade-application notify + one-click email approval ---
   // Every new /trade/apply emails a review card (via George) to this office inbox
   // with Approve/Reject buttons, so Steve approves a designer straight from the inbox.
+  // With TRADE_AUTO_APPROVE on, a SUCCESSFUL auto-approve sends a lighter FYI instead,
+  // and the actionable review card is only sent if an auto-approve FAILS (so a stuck
+  // applicant still surfaces to staff).
   TRADE_NOTIFY_TO: process.env.TRADE_NOTIFY_TO || 'info@designerwallcoverings.com',
   // Public base the Approve/Reject buttons point at. Empty → server.js falls back to
   // the local dev port; at go-live set to the Kamatera host (signup.designer…).
diff --git a/lib/email.js b/lib/email.js
index adfd403..6d094cd 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -359,7 +359,31 @@ function tradeApplicationEmail({ app, approveUrl, rejectUrl, adminUrl }) {
   return { subject, html };
 }
 
+// Staff FYI for a SUCCESSFUL auto-approve (Steve, 2026-09-10). No action needed — the
+// account is already tagged `trade` + the applicant emailed. Sent to the office inbox so
+// staff still SEE every trade signup (the original "staff not receiving the forms" gap),
+// just without an approve button. On auto-approve FAILURE the actionable review card
+// (tradeApplicationEmail) is sent instead, so a stuck applicant still surfaces.
+function tradeAutoApprovedEmail({ app, repName, adminUrl }) {
+  const subject = `✅ Trade account auto-approved — ${app.business_name || app.email}`;
+  const row = (label, val) => `<tr><td style="padding:2px 12px 2px 0;color:#6b7280">${label}</td><td>${val}</td></tr>`;
+  const html = [
+    `<p>A new <b>trade / designer</b> account was <b>auto-approved on signup</b> (no action needed):</p>`,
+    `<table style="border-collapse:collapse;font-size:14px;margin:6px 0 14px">`,
+    row('Business', `<b>${esc(app.business_name || '—')}</b>`),
+    row('Email', esc(app.email || '—')),
+    row('Phone', esc(app.phone || '—')),
+    row('Resale cert', esc(app.resale_cert || '—')),
+    row('Assigned rep', esc(repName || '—')),
+    row('Applied', esc(app.created_at || '—')),
+    row('ID', `<span style="font-family:monospace;font-size:12px">${esc(app.id)}</span>`),
+    `</table>`,
+    `<p style="font-size:12px;color:#9ca3af">Tagged <code>trade</code> in Shopify (unlimited free memo samples) and the applicant has been emailed. <a href="${esc(adminUrl)}">Open the admin panel</a> if you need to review or reverse it.</p>`,
+  ].join('\n');
+  return { subject, html };
+}
+
 function money(v) { return `$${Number(v).toFixed(2)}`; }
 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
 
-module.exports = { sendEmail, verifyEmail, verifyResendEmail, samplesUnlockedEmail, retailCodeEmail, retailGiftEmail, designerWelcomeEmail, designerAccountReadyEmail, repNotifyEmail, tradeApprovedEmail, tradeRejectedEmail, tradeApplicationEmail, money, esc };
+module.exports = { sendEmail, verifyEmail, verifyResendEmail, samplesUnlockedEmail, retailCodeEmail, retailGiftEmail, designerWelcomeEmail, designerAccountReadyEmail, repNotifyEmail, tradeApprovedEmail, tradeRejectedEmail, tradeApplicationEmail, tradeAutoApprovedEmail, money, esc };
diff --git a/lib/trade.js b/lib/trade.js
index d0cb433..2c0b4b0 100644
--- a/lib/trade.js
+++ b/lib/trade.js
@@ -151,6 +151,16 @@ function listPending() {
   return readAll().filter(a => a.status === 'pending').sort((a, b) => a.created_at.localeCompare(b.created_at));
 }
 
+// Guarded auto-approve dedupe (Steve, 2026-09-10): true if this email already has an
+// APPROVED application, so an exact-duplicate re-submit is not re-approved / re-emailed.
+// Case-insensitive on the whole address (Shopify treats the local part case-insensitively
+// in practice, and our intake lowercases before persist).
+function emailAlreadyApproved(emailAddr) {
+  const norm = String(emailAddr || '').trim().toLowerCase();
+  if (!norm) return false;
+  return readAll().some(a => a.status === 'approved' && String(a.email || '').trim().toLowerCase() === norm);
+}
+
 function get(id) {
   return readAll().find(a => a.id === id) || null;
 }
@@ -339,4 +349,4 @@ function summarizeShopify(r) {
   return { status: r.status, ok: r.ok };
 }
 
-module.exports = { apply, applyAndLink, listPending, get, approve, reject, readAll, APPS_PATH, actionToken, verifyActionToken };
+module.exports = { apply, applyAndLink, listPending, emailAlreadyApproved, get, approve, reject, readAll, APPS_PATH, actionToken, verifyActionToken };
diff --git a/server.js b/server.js
index 738d84d..00ad0cb 100644
--- a/server.js
+++ b/server.js
@@ -147,10 +147,20 @@ app.options('/trade/apply', tradeCors);
 app.post('/trade/apply', tradeCors, async (req, res) => {
   const b = req.body || {};
   if (!b.email) return res.status(400).json({ ok: false, error: 'email required' });
+  // GUARDRAIL (auto-approve): reject blank/invalid emails at intake so a malformed address
+  // never creates a Shopify customer or an auto-approved trade account (same check as /claim).
+  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(b.email).trim())) {
+    return res.status(400).json({ ok: false, error: 'valid email required' });
+  }
   // TK-11185: throttle per IP — the intake now creates a real Shopify customer per POST,
   // so an unthrottled public endpoint could be sprayed to pollute the customer table /
   // spam the designer-welcome email. Same sliding-window util as the webhook.
   if (tradeApplyLimiter(clientIp(req))) return res.status(429).json({ ok: false, error: 'rate_limited' });
+  // GUARDRAIL (auto-approve dedupe): an exact re-submit of an email that ALREADY has an
+  // approved trade account is a no-op — don't create a duplicate customer/app or re-email.
+  if (config.TRADE_AUTO_APPROVE && trade.emailAlreadyApproved(String(b.email).trim())) {
+    return res.json({ ok: true, status: 'approved', already: true });
+  }
   // TK-11185: synchronously find-or-create the Shopify customer + stamp the id, so the
   // application is born LINKED and approve() can never hard-fail cannot_resolve_customer.
   // DW is on New Customer Accounts (OTP) so the account is minted server-side (Admin API),
@@ -167,18 +177,22 @@ app.post('/trade/apply', tradeCors, async (req, res) => {
   if (!linkage.ok) {
     console.error(`[trade] application ${created.id} (${created.email}) persisted UNLINKED (${linkage.error}) — review /admin/trade; resolvable in a later recovery pass.`);
   }
-  // Email the office inbox a review card with one-click Approve/Reject buttons — fired ONLY
-  // after the link attempt resolved, so a linked app never yields an un-approvable card.
-  // Fire-and-forget so the applicant's response isn't blocked on George; DRY_RUN-safe.
-  notifyTradeApplication(created).catch(e => console.error('[trade] notify failed:', e.message));
-  // Also send the applicant the "about us + services" welcome letter (approved 2026-08-07).
-  // Fire-and-forget + DRY_RUN-safe; a George hiccup must not fail the applicant's submit.
-  (async () => {
-    const first = created.first_name || (created.contact_name ? String(created.contact_name).split(' ')[0] : '') || (created.email ? created.email.split('@')[0] : '');
-    const { subject, html } = email.designerWelcomeEmail({ firstName: first });
-    const r = await email.sendEmail({ to: created.email, subject, html, source: 'designer-welcome' });
-    if (r && r.ok === false) console.error(`[trade] designer-welcome send FAILED for ${created.email}: ${r.error || r.status}`);
-  })().catch(e => console.error('[trade] designer-welcome error:', e.message));
+  // AUTO-APPROVE ON SIGNUP (Steve, 2026-09-10, guarded). Fire-and-forget so the applicant's
+  // POST isn't blocked on Shopify+George; DRY_RUN-safe (approve() only simulates in dev).
+  // On success approve() emails the applicant "you're approved" + a staff FYI goes to the
+  // office — so we DON'T also send the separate designer-welcome (avoids a redundant email).
+  if (config.TRADE_AUTO_APPROVE) {
+    autoApproveAndNotify(created).catch(e => console.error('[trade] auto-approve failed:', e.message));
+  } else {
+    // Legacy path (TRADE_AUTO_APPROVE=0): park pending + email the review card + welcome letter.
+    notifyTradeApplication(created).catch(e => console.error('[trade] notify failed:', e.message));
+    (async () => {
+      const first = created.first_name || (created.contact_name ? String(created.contact_name).split(' ')[0] : '') || (created.email ? created.email.split('@')[0] : '');
+      const { subject, html } = email.designerWelcomeEmail({ firstName: first });
+      const r = await email.sendEmail({ to: created.email, subject, html, source: 'designer-welcome' });
+      if (r && r.ok === false) console.error(`[trade] designer-welcome send FAILED for ${created.email}: ${r.error || r.status}`);
+    })().catch(e => console.error('[trade] designer-welcome error:', e.message));
+  }
   res.json({ ok: true, id: created.id, status: created.status, created_at: created.created_at, linked: !!linkage.ok });
 });
 
@@ -269,6 +283,27 @@ async function notifyTradeApplication(application) {
   return r;
 }
 
+// Auto-approve a fresh application (Steve, 2026-09-10, guarded). Approves via the SAME
+// trade.approve() path the one-click card uses (idempotent + DRY_RUN-safe: it only
+// simulates in dev, returning ok:false+dryRun). On a LIVE success it sends a staff FYI so
+// the office still sees every signup; on DRY_RUN or a LIVE approve FAILURE it falls back to
+// the actionable review card so a stuck applicant still surfaces to staff.
+async function autoApproveAndNotify(application) {
+  const r = await trade.approve(application.id);
+  if (r && r.ok) {
+    const fresh = trade.get(application.id) || application;
+    const repName = fresh.assigned_rep && fresh.assigned_rep.name;
+    const { subject, html } = email.tradeAutoApprovedEmail({ app: fresh, repName, adminUrl: `${baseUrl()}/admin/trade` });
+    await email.sendEmail({ to: config.TRADE_NOTIFY_TO, subject, html, source: 'trade-auto-approved' });
+    return r;
+  }
+  // DRY_RUN simulation (r.dryRun) or a genuine LIVE failure (r.ok===false): send the
+  // review card so staff can approve manually. notifyTradeApplication dead-letters a
+  // George outage, so the applicant is never a black hole.
+  await notifyTradeApplication(application);
+  return r;
+}
+
 // Small confirmation page rendered when Approve/Reject is clicked from the email.
 function resultPage(msg, ok) {
   return `<!doctype html><meta charset="utf-8"><title>DW Trade</title>` +

← 52e57ab TK-11366: DTD verdict - A-prime (close the $45 gap first, th  ·  back to Dw Signup Fulfillment  ·  TK-11366: read-only app forensics - recover install order, s b2704f9 →