[object Object]

← back to Pitch Guard

Refine: never bother clients who already transacted or were already followed up

7308e45e42e98ceafb4ac72168d682b15d56b7f8 · 2026-06-16 14:53:13 -0700 · SteveStudio2

Root cause (2026-06-16): the paid/shipped risk-gate lived ONLY in the UI Send
button, so a native Gmail-web send bypassed it — already-closed clients
(incl. one PAID+ORDERED+SHIPPED) got pitched a quote followup.

- assessRisk() + alreadyFollowedUp() shared probes
- candidate scanner now EXCLUDES level>=2 (ORDERED/INVOICED/PAID/SHIPPED) and
  anyone followed up in last 21d — they never become candidates, so never drafted
- /api/followup/create refuses to draft a transacted contact (defense-in-depth)
- swept + trashed today's followup drafts to transacted clients (gsmsriedel/louie/
  nicole/jgarner/sis9lives/tmmd44/daddydas) + duplicate drafts to already-sent ones

Files touched

Diff

commit 7308e45e42e98ceafb4ac72168d682b15d56b7f8
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue Jun 16 14:53:13 2026 -0700

    Refine: never bother clients who already transacted or were already followed up
    
    Root cause (2026-06-16): the paid/shipped risk-gate lived ONLY in the UI Send
    button, so a native Gmail-web send bypassed it — already-closed clients
    (incl. one PAID+ORDERED+SHIPPED) got pitched a quote followup.
    
    - assessRisk() + alreadyFollowedUp() shared probes
    - candidate scanner now EXCLUDES level>=2 (ORDERED/INVOICED/PAID/SHIPPED) and
      anyone followed up in last 21d — they never become candidates, so never drafted
    - /api/followup/create refuses to draft a transacted contact (defense-in-depth)
    - swept + trashed today's followup drafts to transacted clients (gsmsriedel/louie/
      nicole/jgarner/sis9lives/tmmd44/daddydas) + duplicate drafts to already-sent ones
---
 server.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 47 insertions(+)

diff --git a/server.js b/server.js
index 6edeb15..5aade7a 100644
--- a/server.js
+++ b/server.js
@@ -85,6 +85,39 @@ const SIGNALS = [
   { key: 'ordered',  level: 2, label: 'ORDERED',  re: /\b(order #?\d|order confirmation|order placed|your order|purchase order|p\.?o\.? ?#?\d)\b/i },
   { key: 'invoiced', level: 2, label: 'INVOICED', re: /\b(invoice ?#?\d|invoice attached|here('| i)s your invoice|invoice for)\b/i },
 ];
+// level ≥ this means "already transacted — do not bother with a quote followup"
+const BLOCK_LEVEL = 2;
+
+// Phrasings the followup bot uses — to detect we've ALREADY nudged this contact.
+const FU_RE = /follow[\s-]?up|following up|check(ing)? in|circl(e|ing) back|haven'?t heard|gentle (nudge|reminder)|chase up|did(n'?t| not) (you )?see/i;
+
+// Reusable transaction-risk probe (the Samantha tripwire). Shared by the candidate
+// scanner AND the draft-create guard so a client who already ordered/paid/shipped is
+// NEVER surfaced as a candidate and NEVER drafted — not merely blocked at the Send
+// button (a native Gmail-web send bypasses that button entirely, which is how
+// already-closed clients got pitched on 2026-06-16).
+async function assessRisk(email) {
+  email = String(email || '').toLowerCase();
+  let level = 0; const labels = new Set();
+  try {
+    const out = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `from:${email} OR to:${email}`, maxResults: 40 } });
+    for (const m of (out.messages || [])) {
+      if (m.error) continue;
+      const hay = `${m.subject || ''} ${m.snippet || ''}`;
+      for (const s of SIGNALS) if (s.re.test(hay)) { level = Math.max(level, s.level); labels.add(s.label); }
+    }
+  } catch (e) { return { level: 0, labels: [], error: e.message }; }
+  return { level, labels: [...labels] };
+}
+
+// Have we already SENT this contact a followup in the last 21 days? (in:sent only —
+// an unsent draft doesn't count.) Used to avoid double-nudging someone.
+async function alreadyFollowedUp(email) {
+  try {
+    const fu = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `in:sent to:${email} newer_than:21d`, maxResults: 10 } });
+    return (fu.messages || []).some(m => !m.error && FU_RE.test(`${m.subject || ''} ${m.snippet || ''}`));
+  } catch { return false; }
+}
 
 // ── vendor vs client classification ──────────────────────────────────────
 // The followup bot drafts "following on the quote" replies to vendor support
@@ -242,6 +275,12 @@ async function runCandidateScan() {
       const dr = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `in:draft to:${contact}`, maxResults: 2 } });
       if ((dr.messages || []).some(x => !x.error)) { await sleep(150); continue; }      // already drafted
     } catch (e) { uncertain = true; }
+    // transaction tripwire: if they already ordered/paid/shipped, they are NOT a
+    // followup candidate — never surface them, so they can never be drafted/sent.
+    const risk = await assessRisk(contact);
+    if (risk.level >= BLOCK_LEVEL) { await sleep(150); continue; }   // already transacted → leave alone
+    // and don't double-nudge someone we already followed up recently.
+    if (await alreadyFollowedUp(contact)) { await sleep(150); continue; }
     candidates.push({
       threadId: info.msg.threadId,
       subject: info.msg.subject || '(no subject)',
@@ -484,6 +523,14 @@ app.post('/api/followup/create', async (req, res) => {
   try {
     const { to, subject } = req.body || {};
     if (!to) return res.status(400).json({ error: 'to required' });
+    // defense-in-depth: NEVER create a followup draft for someone who already
+    // transacted — even if a caller (or stale UI) asks for it. The candidate
+    // scanner already excludes them; this is the second wall.
+    if (!req.body.override) {
+      const risk = await assessRisk(to);
+      if (risk.level >= BLOCK_LEVEL)
+        return res.status(409).json({ error: 'blocked: contact already transacted (' + risk.labels.join(', ') + ') — not drafting a quote followup', risk: risk.labels });
+    }
     const subj = /^re:/i.test(subject || '') ? subject : 'Re: ' + (subject || 'your inquiry');
     const out = await george('/api/drafts', { method: 'POST', body: { account: ACCOUNT, to, subject: subj, body: FOLLOWUP_BODY, no_source_tag: true } });
     candCache = null;        // invalidate so a rescan reflects the new draft

← 0d9ee55 Fix non-deterministic followup-candidate scanner: single-fli  ·  back to Pitch Guard  ·  Add Designer Wallcoverings logo to header; (GEORGE_URL fixed 75b161a →