[object Object]

← back to Pitch Guard

feature: surface invoice-sent & samples-sent as warm followup signals + sort criteria

aae4190291a7a18830eb3516e0a045864da608ab · 2026-06-30 05:19:50 -0700 · Steve

- detectWarmSent(): in:sent scan for invoice/samples/swatch/memo (outbound only)
- candidate scan tags warmSignals + sentInvoice/SamplesTs, sorts warm leads first
- assessRisk now blocks only paid/shipped/ordered (an unpaid invoice we SENT is a
  warm lead, not a closed sale) — Samantha tripwire (paid/shipped/order) intact
- UI: green INVOICE SENT / SAMPLES SENT chips on candidate rows + Invoice-sent /
  Samples-sent sort options

Files touched

Diff

commit aae4190291a7a18830eb3516e0a045864da608ab
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 30 05:19:50 2026 -0700

    feature: surface invoice-sent & samples-sent as warm followup signals + sort criteria
    
    - detectWarmSent(): in:sent scan for invoice/samples/swatch/memo (outbound only)
    - candidate scan tags warmSignals + sentInvoice/SamplesTs, sorts warm leads first
    - assessRisk now blocks only paid/shipped/ordered (an unpaid invoice we SENT is a
      warm lead, not a closed sale) — Samantha tripwire (paid/shipped/order) intact
    - UI: green INVOICE SENT / SAMPLES SENT chips on candidate rows + Invoice-sent /
      Samples-sent sort options
---
 server.js | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 53 insertions(+), 5 deletions(-)

diff --git a/server.js b/server.js
index df2ce50..3ca2004 100644
--- a/server.js
+++ b/server.js
@@ -94,6 +94,19 @@ const SIGNALS = [
 // level ≥ this means "already transacted — do not bother with a quote followup"
 const BLOCK_LEVEL = 2;
 
+// Outbound "warm lead" signals — things WE sent that mean the client is MID-funnel and
+// NEEDS a nudge (not a completed sale): an invoice awaiting payment, or samples out for
+// evaluation. Steve's ask (2026-06-30): surface + sort by these to FIND who needs a
+// followup. They never block — an invoice we SENT is the opposite of a closed sale.
+const WARM_SIGNALS = [
+  { key: 'invoice_sent', label: 'INVOICE SENT', re: /\binvoice/i },
+  { key: 'samples_sent', label: 'SAMPLES SENT', re: /\b(samples?|swatch(?:es)?|memo sample|cutting)\b/i },
+];
+// ONLY these mean "already closed — leave them alone." 'invoiced' is intentionally NOT
+// here: an unpaid invoice we sent is a warm followup target, not a closed deal. paid /
+// shipped / a confirmed order still hard-block (the Samantha tripwire stays intact).
+const BLOCK_SIGNAL_KEYS = new Set(['paid', 'shipped', 'ordered']);
+
 // 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;
 
@@ -112,8 +125,32 @@ async function assessRisk(email) {
       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] };
+  } catch (e) { return { level: 0, labels: [], blocked: false, error: e.message }; }
+  // blocked = truly closed (paid/shipped/ordered). An invoice alone no longer blocks.
+  const blocked = SIGNALS.some(s => BLOCK_SIGNAL_KEYS.has(s.key) && labels.has(s.label));
+  return { level, labels: [...labels], blocked };
+}
+
+// Did WE send this contact an invoice or samples? (in:sent → OUTBOUND only, so an
+// inbound "here's our invoice" from a vendor never counts.) These are the warm-lead
+// signals Steve wants surfaced + sortable to find who most needs a followup. Returns
+// e.g. { invoice_sent: {ts,date,label}, samples_sent: {ts,date,label} }.
+async function detectWarmSent(email) {
+  email = String(email || '').toLowerCase();
+  const found = {};
+  try {
+    const q = `in:sent to:${email} (invoice OR sample OR samples OR swatch OR swatches OR "memo sample" OR cutting)`;
+    const r = await georgeRetry('/api/search', { query: { account: ACCOUNT, q, maxResults: 12 } });
+    for (const m of (r.messages || [])) {
+      if (m.error) continue;
+      const hay = `${m.subject || ''} ${m.snippet || ''}`;
+      const ts = Date.parse(m.date) || 0;
+      for (const s of WARM_SIGNALS) {
+        if (s.re.test(hay) && (!found[s.key] || ts > found[s.key].ts)) found[s.key] = { ts, date: m.date, label: s.label };
+      }
+    }
+  } catch (e) { /* advisory — never block a candidate on a warm-scan failure */ }
+  return found;
 }
 
 // Have we already SENT this contact a followup in the last 21 days? (in:sent only —
@@ -284,18 +321,29 @@ async function runCandidateScan() {
     // 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
+    if (risk.blocked) { await sleep(150); continue; }   // paid/shipped/ordered → truly closed, leave alone
     // and don't double-nudge someone we already followed up recently.
     if (await alreadyFollowedUp(contact)) { await sleep(150); continue; }
+    // warm-lead signals: did WE send them an invoice or samples? Those are the
+    // highest-priority follow targets (mid-funnel, about to be lost if not nudged).
+    const warm = await detectWarmSent(contact);
+    const warmSignals = Object.values(warm).map(w => w.label);
     candidates.push({
       threadId: info.msg.threadId,
       subject: info.msg.subject || '(no subject)',
       contact, contactName: displayName(info.msg.to) || contact,
       lastDate: info.msg.date, daysSince, uncertain,
+      warmSignals,
+      sentInvoiceTs: warm.invoice_sent ? warm.invoice_sent.ts : 0,
+      sentSamplesTs: warm.samples_sent ? warm.samples_sent.ts : 0,
     });
     await sleep(150);
   }
-  candidates.sort((a, b) => b.daysSince - a.daysSince);
+  // Warm leads (invoice/samples we sent) first — those most need a follow — then by how
+  // long they've gone quiet. (The UI can re-sort to invoice-only / samples-only.)
+  candidates.sort((a, b) =>
+    ((b.warmSignals && b.warmSignals.length ? 1 : 0) - (a.warmSignals && a.warmSignals.length ? 1 : 0))
+    || b.daysSince - a.daysSince);
   const payload = { count: candidates.length, scanned: contacts.length,
     uncertainCount: candidates.filter(c => c.uncertain).length,
     candidates, scannedAt: new Date().toISOString() };
@@ -535,7 +583,7 @@ app.post('/api/followup/create', async (req, res) => {
     // scanner already excludes them; this is the second wall.
     if (!req.body.override) {
       const risk = await assessRisk(to);
-      if (risk.level >= BLOCK_LEVEL)
+      if (risk.blocked)
         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');

← ca01242 Pitch Guard: auto-remove a draft from the list once its foll  ·  back to Pitch Guard  ·  auto-save: 2026-06-30T05:58:45 (1 files) — server.js 4932ed3 →