← 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 →