# Messages System Investigation Report

**Date:** Jan 18, 2026
**Incident:** Hot lead (Liam) said "Yes we need a heep of good men" + "Plz send them on" - system responded with 3 generic auto-replies in 1 minute, no notifications reached user.

---

## SMS INBOUND FLOW (Current State)

```
Twilio receives SMS
        │
        ▼
POST /api/webhooks/twilio/inbound  (webhooks.js:29)
        │
        ├─► Validate Twilio signature
        │
        ├─► Normalize phone number
        │
        ├─► Look up lead by phone (3 formats tried)
        │
        ├─► classifyIntent(Body) → {intent, buyingSignal}  ◄── BUYING SIGNAL DETECTED HERE
        │
        ├─► Insert to communications table (with buying signal metadata)
        │
        ├─► Update lead.metadata.buying_signals array
        │
        ├─► Update lead last_contact_at, status
        │
        ├─► Create callback if positive/question
        │
        ├─► sendInboundAlert() → Slack notification  ◄── NOTIFICATION SENT HERE
        │
        ├─► getAutoReply(intent, leadName) → DUMB REPLY  ◄── IGNORES BUYING SIGNAL!
        │
        ├─► sendSMS(autoReply)  ◄── NO RATE LIMIT OR DEDUPLICATION!
        │
        ├─► Log auto-reply to communications
        │
        └─► ASYNC: extractInboundIntel() (fire and forget)
```

---

## FAILURE 1: AUTO-REPLY SPAM (3x in 1 minute)

### Root Cause
**NO DEDUPLICATION OR RATE LIMITING** on auto-replies.

**File:** `src/routes/webhooks.js` lines 290-312

```javascript
// Lines 290-293 - ALWAYS fires, no deduplication check
const leadName = lead?.first_name || null;
const autoReply = getAutoReply(intent, leadName);
const replyResult = await sendSMS(normalizedPhone || From, autoReply);
```

**Why 3x in 1 minute:**
- Twilio retries webhooks on timeout/5xx errors
- If our server is slow (Railway cold start), Twilio sends 2-3 retries
- Each retry triggers a new auto-reply
- Result: 3 identical generic messages

### Fix Required
Add deduplication check before sending auto-reply:
```javascript
// Check if we already replied to this number recently (last 5 minutes)
const { data: recentReplies } = await supabase
  .from('communications')
  .select('id')
  .eq('phone', normalizedPhone)
  .eq('type', 'sms_outbound')
  .eq('metadata->>auto_reply', 'true')
  .gte('created_at', new Date(Date.now() - 5 * 60 * 1000).toISOString())
  .limit(1);

if (recentReplies?.length > 0) {
  console.log('Skipping auto-reply - already replied recently');
} else {
  // Send auto-reply
}
```

---

## FAILURE 2: DUMB AUTO-REPLIES (Should Be Smart)

### Root Cause
**`getAutoReply()` IGNORES buying signals completely.**

**File:** `src/utils/intent.js` lines 195-208

```javascript
function getAutoReply(intent, leadName = null) {
  const name = leadName ? ` ${leadName}` : '';

  switch (intent) {
    case 'positive':
      return `Thanks${name}! One of our team will call you shortly...`;  // GENERIC!
    case 'negative':
      return `No worries${name}. You've been removed...`;
    case 'question':
      return `Thanks for your question${name}!...`;
    default:
      return `Thanks for your message${name}. Our team will review...`;  // THIS IS WHAT FIRED
  }
}
```

**The message "Yes we need a heep of good men" would classify as:**
- Intent: `positive` (matches "yes" pattern)
- Buying Signal: `high` (matches "need.*workers" pattern in `BUYING_SIGNAL_PATTERNS.medium`)

**But `getAutoReply()` only takes `intent` - it ignores `buyingSignal` entirely!**

### Additional Problem
Auto-reply should NOT fire on high-intent messages. When someone says "we need workers" - that's a hot lead! Don't auto-reply, ESCALATE TO HUMAN IMMEDIATELY.

### Fix Required
1. **Don't auto-reply on high/medium buying signals:**
```javascript
// In webhooks.js, before auto-reply:
if (buyingSignal?.level === 'high' || buyingSignal?.level === 'medium') {
  console.log(`HIGH INTENT DETECTED - skipping auto-reply, escalating`);
  // Send urgent Slack/push notification instead
  await sendUrgentBuyingSignalAlert(lead, Body, buyingSignal);
  return; // Don't send generic auto-reply
}
```

2. **If we DO auto-reply, make it context-aware:**
```javascript
function getSmartAutoReply(intent, buyingSignal, leadName, messageBody) {
  if (buyingSignal?.level === 'high') {
    return null; // Don't auto-reply - escalate instead
  }

  if (buyingSignal?.level === 'medium') {
    return `Thanks ${leadName}! I can see you're interested - I'm jumping on this right now and will call you within the hour.`;
  }

  // ... existing logic for low-intent
}
```

---

## FAILURE 3: NO NOTIFICATIONS

### Root Cause
**Slack notification IS being sent** (line 288), but likely one of:
1. `SLACK_WEBHOOK_URL` not configured in Railway
2. Slack channel muted/archived
3. Wrong Slack workspace

**File:** `src/routes/webhooks.js` line 288
```javascript
await sendInboundAlert(lead, Body, intent, formatPhoneDisplay(normalizedPhone || From));
```

**File:** `src/services/slack.js` lines 10-16
```javascript
async function sendSlackMessage(payload) {
  const webhookUrl = process.env.SLACK_WEBHOOK_URL;

  if (!webhookUrl) {
    console.warn('Slack not configured - missing SLACK_WEBHOOK_URL');
    return { success: false, error: 'Slack not configured' };  // SILENT FAILURE
  }
  // ...
}
```

### Additional Issue: No In-App Notification
The frontend has realtime subscriptions but there's no **push notification** or **audio alert** for inbound SMS from the webhook handler.

**What's missing:**
1. No `notifications` table insert for SMS inbound (only for website signups)
2. No push notification trigger
3. RealtimeContext only watches `communications` table, not for urgent alerts

### Fix Required
1. **Check Railway logs** for "Slack not configured" warning
2. **Verify `SLACK_WEBHOOK_URL`** is set in Railway env vars
3. **Add notifications table insert** for high-intent inbound SMS:
```javascript
// After buying signal detection, if high:
if (buyingSignal?.level === 'high' || buyingSignal?.level === 'medium') {
  await supabase.from('notifications').insert({
    type: 'urgent_inbound_sms',
    title: '🔥 BUYING SIGNAL DETECTED',
    message: `${lead?.first_name || 'Unknown'} just said: "${Body.substring(0, 50)}..."`,
    metadata: { lead_id: lead?.id, buying_signal: buyingSignal }
  });
}
```

---

## FAILURE 4: SMART TEMPLATES NOT WORKING

### Root Cause
**Quick reply chips use STATIC text, not AI.**

**File:** `admin/src/pages/Messages.jsx` lines 363-368
```javascript
const QUICK_REPLIES = [
  { label: 'Following up', message: "Hi! Just following up on our last chat..." },  // STATIC!
  { label: 'Still interested?', message: "Hey! Wanted to check in..." },            // STATIC!
  { label: 'Here to help', message: "Hi there! Just wanted to let you know..." },   // STATIC!
  { label: 'Thanks!', message: "Thanks for getting back to me!..." },               // STATIC!
];
```

**AIMessageWriter EXISTS** (`admin/src/components/AIMessageWriter.jsx`) and is SMART:
- Calls `aiApi.draftMessage()` with leadId, context
- Generates 3 personalized suggestions
- Has tone selector, voice input

**But it's NOT USED in Messages.jsx!** The quick reply chips just insert static text.

### Fix Required
Replace static QUICK_REPLIES with AI-powered suggestions:

Option A: **Replace chips with AIMessageWriter button**
```jsx
// Remove static QUICK_REPLIES
// Add button to open AIMessageWriter
<button onClick={() => setShowAIWriter(true)}>
  <Sparkles /> AI Suggestions
</button>

{showAIWriter && (
  <AIMessageWriter lead={lead} onClose={() => setShowAIWriter(false)} onSend={handleSendMessage} />
)}
```

Option B: **Pre-load AI suggestions as chips**
```jsx
const [smartReplies, setSmartReplies] = useState([]);

useEffect(() => {
  if (selectedConversation?.lead_id) {
    loadSmartReplies(selectedConversation.lead_id);
  }
}, [selectedConversation?.lead_id]);

const loadSmartReplies = async (leadId) => {
  const result = await aiApi.draftMessage({ leadId, count: 3 });
  setSmartReplies(result.suggestions);
};
```

---

## PRIORITY ORDER FOR FIXES

### 1. CRITICAL: Auto-Reply Rate Limit (Embarrassment Prevention)
**Impact:** Spamming leads with 3 identical messages
**Effort:** 30 minutes
**File:** `src/routes/webhooks.js`

### 2. CRITICAL: Don't Auto-Reply on High Intent
**Impact:** Losing hot leads to generic responses
**Effort:** 1 hour
**Files:** `src/routes/webhooks.js`, `src/utils/intent.js`

### 3. HIGH: Verify Slack Notifications
**Impact:** Missing inbound alerts
**Effort:** 15 minutes
**Action:** Check Railway env vars, test webhook

### 4. HIGH: Add In-App Urgent Notifications
**Impact:** No push/audio for buying signals
**Effort:** 2 hours
**Files:** `src/routes/webhooks.js`, `admin/src/context/RealtimeContext.jsx`

### 5. MEDIUM: Smart Quick Replies
**Impact:** Static replies instead of AI-powered
**Effort:** 2 hours
**Files:** `admin/src/pages/Messages.jsx`

---

## SUMMARY

| Failure | Root Cause | Fix |
|---------|------------|-----|
| Auto-reply spam | No deduplication on webhook retries | Add 5-minute recent reply check |
| Dumb auto-replies | `getAutoReply()` ignores buying signals | Skip auto-reply on high intent, escalate instead |
| No notifications | Slack possibly not configured, no in-app push | Verify env vars, add notifications insert |
| Static templates | QUICK_REPLIES is hardcoded, AIMessageWriter not used | Integrate AIMessageWriter or pre-load AI chips |

---

## FILES TO MODIFY

1. `src/routes/webhooks.js` - Add deduplication, skip auto-reply on high intent
2. `src/utils/intent.js` - Make `getAutoReply()` buying-signal-aware (or deprecate)
3. `src/services/slack.js` - Add urgent buying signal alert function
4. `admin/src/pages/Messages.jsx` - Replace static QUICK_REPLIES with AI
5. Railway Environment - Verify `SLACK_WEBHOOK_URL`
