From 5243188d0c9a62f5a8cb9cb2bc2661eafae8fff6 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 20:47:13 +0300 Subject: [PATCH] feat(automations): add 'First Message from Contact' trigger new_contact_created only fires when the webhook auto-creates the contact row, so it misses the case of manually-added / imported contacts sending their first-ever message. first_inbound_message fires whenever the incoming message is the contact's first customer-sent message (checked by counting prior customer-sender messages in the conversation before insert), which covers both auto-created and pre-existing contacts. - Added first_inbound_message to AutomationTriggerType. - New pill color + dropdown entry labeled "First Message from Contact". - Webhook now counts prior customer messages before inserting the incoming one; dispatches first_inbound_message when the count is 0. - new_contact_created still fires for brand-new contacts; they're independent triggers with overlapping coverage, so users pick. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/whatsapp/webhook/route.ts | 29 ++++++++++++++++--- .../automations/automation-builder.tsx | 7 ++++- src/lib/automations/trigger-meta.ts | 4 +++ src/types/index.ts | 1 + 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index f0569fa..13a3181 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -383,6 +383,17 @@ async function processMessage( ? 'image' // stickers are images : 'text' // reaction, unknown → text fallback + // Determine whether this is the contact's very first inbound message + // BEFORE we insert, so the count is accurate. Covers the case where + // the contact row already exists (manual add / CSV import) but they've + // never messaged us before — which new_contact_created wouldn't catch. + const { count: priorCustomerMsgCount } = await supabaseAdmin() + .from('messages') + .select('id', { count: 'exact', head: true }) + .eq('conversation_id', conversation.id) + .eq('sender_type', 'customer') + const isFirstInboundMessage = (priorCustomerMsgCount ?? 0) === 0 + const { error: msgError } = await supabaseAdmin().from('messages').insert({ conversation_id: conversation.id, sender_type: 'customer', @@ -425,10 +436,20 @@ async function processMessage( // Fire-and-forget: a slow or failing automation must not block the // webhook's 200 OK response to Meta. const inboundText = contentText ?? message.text?.body ?? '' - const automationTriggers: ('new_contact_created' | 'new_message_received' | 'keyword_match')[] = - contactOutcome.wasCreated - ? ['new_contact_created', 'new_message_received', 'keyword_match'] - : ['new_message_received', 'keyword_match'] + const automationTriggers: ( + | 'new_contact_created' + | 'first_inbound_message' + | 'new_message_received' + | 'keyword_match' + )[] = ['new_message_received', 'keyword_match'] + // new_contact_created fires only when the webhook just auto-created the + // contact row. first_inbound_message fires whenever this is the contact's + // first-ever customer-sent message — a superset that also catches + // manually-imported contacts sending for the first time. We dispatch both + // so users can pick whichever semantic they want; an automation that + // listens to only one trigger runs only when that trigger matches. + if (contactOutcome.wasCreated) automationTriggers.unshift('new_contact_created') + if (isFirstInboundMessage) automationTriggers.unshift('first_inbound_message') for (const triggerType of automationTriggers) { runAutomationsForTrigger({ userId, diff --git a/src/components/automations/automation-builder.tsx b/src/components/automations/automation-builder.tsx index 7371e01..04d3a08 100644 --- a/src/components/automations/automation-builder.tsx +++ b/src/components/automations/automation-builder.tsx @@ -106,8 +106,13 @@ const ADDABLE_STEPS: AutomationStepType[] = [ const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string; hint: string }[] = [ { value: "new_message_received", label: "New Message Received", hint: "Any incoming message" }, + { + value: "first_inbound_message", + label: "First Message from Contact", + hint: "First time this contact ever messages you (works for manually-added contacts too)", + }, { value: "keyword_match", label: "Keyword Match", hint: "Message contains specific keyword(s)" }, - { value: "new_contact_created", label: "New Contact Created", hint: "When a contact is auto-created" }, + { value: "new_contact_created", label: "New Contact Created", hint: "When a contact is auto-created from an incoming message" }, { value: "conversation_assigned", label: "Conversation Assigned", hint: "When assigned to an agent" }, { value: "tag_added", label: "Tag Added", hint: "When a tag is added to a contact" }, { value: "time_based", label: "Time-Based", hint: "On a recurring schedule" }, diff --git a/src/lib/automations/trigger-meta.ts b/src/lib/automations/trigger-meta.ts index 2b05466..b733dd4 100644 --- a/src/lib/automations/trigger-meta.ts +++ b/src/lib/automations/trigger-meta.ts @@ -11,6 +11,10 @@ export const TRIGGER_META: Record = { label: 'New Message', pillClass: 'border-blue-500/30 bg-blue-500/10 text-blue-300', }, + first_inbound_message: { + label: 'First Message from Contact', + pillClass: 'border-teal-500/30 bg-teal-500/10 text-teal-300', + }, keyword_match: { label: 'Keyword Match', pillClass: 'border-purple-500/30 bg-purple-500/10 text-purple-300', diff --git a/src/types/index.ts b/src/types/index.ts index e60c9b9..30a688d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -214,6 +214,7 @@ export interface BroadcastRecipient { export type AutomationTriggerType = | 'new_message_received' + | 'first_inbound_message' | 'keyword_match' | 'new_contact_created' | 'conversation_assigned'