diff --git a/src/app/api/whatsapp/send/route.test.ts b/src/app/api/whatsapp/send/route.test.ts new file mode 100644 index 0000000..dc0fb57 --- /dev/null +++ b/src/app/api/whatsapp/send/route.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// --------------------------------------------------------------------------- +// Tests for the `contact_id` send path (issue #296): sending an approved +// template to a single contact from the Contact detail view. The route must +// find-or-create the contact's conversation server-side, then run the normal +// send + persistence path — no inbound message required to bootstrap a thread. +// --------------------------------------------------------------------------- + +// Records of what the route wrote, so we can assert the right rows landed. +const conversationInserts: Array> = [] +const messageInserts: Array> = [] + +// Toggles for the per-test scenario. +let existingConversation: Record | null = null +let contactRow: Record | null = null + +const CONTACT = { + id: 'contact-1', + account_id: 'acct-1', + phone: '+15551234567', +} + +// Chainable Supabase mock. A fresh builder per `.from()` call tracks whether +// `.insert()` ran so the terminal resolves to the inserted row for creates +// and the canned select row otherwise. +function makeSupabaseMock() { + function builder(table: string) { + let didInsert = false + + const selectResult = () => { + switch (table) { + case 'profiles': + return { data: { account_id: 'acct-1' }, error: null } + case 'contacts': + return { data: contactRow, error: null } + case 'conversations': + return { data: existingConversation, error: null } + case 'whatsapp_config': + return { + data: { + id: 'cfg-1', + account_id: 'acct-1', + phone_number_id: 'PNID-1', + access_token: 'enc-token', + }, + error: null, + } + case 'message_templates': + return { data: null, error: null } + default: + return { data: null, error: null } + } + } + + const insertResult = () => { + switch (table) { + case 'conversations': + return { + data: { + id: 'conv-new', + account_id: 'acct-1', + contact_id: 'contact-1', + contact: CONTACT, + }, + error: null, + } + case 'messages': + return { data: { id: 'msg-1' }, error: null } + default: + return { data: null, error: null } + } + } + + const terminal = () => + Promise.resolve(didInsert ? insertResult() : selectResult()) + + const b: Record = {} + const chain = () => b + for (const m of ['select', 'eq', 'in', 'order', 'limit', 'update', 'delete']) { + b[m] = vi.fn(chain) + } + b.insert = vi.fn((payload: Record) => { + didInsert = true + if (table === 'conversations') conversationInserts.push(payload) + if (table === 'messages') messageInserts.push(payload) + return b + }) + b.single = vi.fn(terminal) + b.maybeSingle = vi.fn(terminal) + b.then = (resolve: (v: unknown) => unknown) => + resolve(didInsert ? insertResult() : selectResult()) + return b + } + + return { + auth: { + getUser: vi.fn(async () => ({ + data: { user: { id: 'user-1' } }, + error: null, + })), + }, + from: vi.fn((table: string) => builder(table)), + } +} + +let supabaseMock = makeSupabaseMock() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(async () => supabaseMock), +})) + +vi.mock('@/lib/flows/admin-client', () => ({ + supabaseAdmin: () => ({ + from: () => { + const b: Record = {} + const chain = () => b + for (const m of ['update', 'eq', 'select']) b[m] = vi.fn(chain) + b.then = (resolve: (v: unknown) => unknown) => + resolve({ data: null, error: null }) + return b + }, + }), +})) + +vi.mock('@/lib/whatsapp/encryption', () => ({ + decrypt: vi.fn(() => 'plaintext-token'), + encrypt: vi.fn(() => 'enc-token'), + isLegacyFormat: vi.fn(() => false), +})) + +const { sendTemplateMessage } = vi.hoisted(() => ({ + sendTemplateMessage: vi.fn(async () => ({ messageId: 'wamid-1' })), +})) +vi.mock('@/lib/whatsapp/meta-api', () => ({ + sendTemplateMessage, + sendTextMessage: vi.fn(), + sendMediaMessage: vi.fn(), +})) + +import { POST } from './route' + +function postContactTemplate(overrides: Record = {}) { + return POST( + new Request('http://localhost/api/whatsapp/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contact_id: 'contact-1', + message_type: 'template', + template_name: 'order_update', + template_language: 'en_US', + template_message_params: { body: ['Acme', '#1234'] }, + template_params: ['Acme', '#1234'], + ...overrides, + }), + }), + ) +} + +describe('POST /api/whatsapp/send — contact_id template path', () => { + beforeEach(() => { + conversationInserts.length = 0 + messageInserts.length = 0 + existingConversation = null + contactRow = CONTACT + supabaseMock = makeSupabaseMock() + sendTemplateMessage.mockClear() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('creates a conversation for a contact with none, then sends the template', async () => { + const res = await postContactTemplate() + const json = await res.json() + + expect(res.status).toBe(200) + expect(json.success).toBe(true) + expect(json.whatsapp_message_id).toBe('wamid-1') + + // A conversation was created for this contact. + expect(conversationInserts).toHaveLength(1) + expect(conversationInserts[0]).toMatchObject({ + account_id: 'acct-1', + contact_id: 'contact-1', + }) + + // The template was sent to the contact's number. + expect(sendTemplateMessage).toHaveBeenCalledTimes(1) + const args = (sendTemplateMessage.mock.calls[0] as unknown[])[0] as Record< + string, + unknown + > + // Meta wants the bare E.164 digits — sanitizePhoneForMeta strips the '+'. + expect(args.to).toBe('15551234567') + expect(args.templateName).toBe('order_update') + + // The outbound message was persisted under the new conversation. + expect(messageInserts).toHaveLength(1) + expect(messageInserts[0]).toMatchObject({ + conversation_id: 'conv-new', + content_type: 'template', + template_name: 'order_update', + sender_type: 'agent', + }) + }) + + it('reuses an existing conversation instead of creating a duplicate', async () => { + existingConversation = { + id: 'conv-existing', + account_id: 'acct-1', + contact_id: 'contact-1', + contact: CONTACT, + } + + const res = await postContactTemplate() + expect(res.status).toBe(200) + + expect(conversationInserts).toHaveLength(0) + expect(messageInserts[0]).toMatchObject({ conversation_id: 'conv-existing' }) + }) + + it('404s when the contact is not in the caller account', async () => { + contactRow = null + + const res = await postContactTemplate() + const json = await res.json() + + expect(res.status).toBe(404) + expect(json.error).toMatch(/contact not found/i) + expect(sendTemplateMessage).not.toHaveBeenCalled() + }) + + it('400s when neither conversation_id nor contact_id is provided', async () => { + const res = await POST( + new Request('http://localhost/api/whatsapp/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message_type: 'template', template_name: 'x' }), + }), + ) + expect(res.status).toBe(400) + }) +}) diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index 107c493..681d9dc 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -64,7 +64,11 @@ export async function POST(request: Request) { const body = await request.json() const { - conversation_id, + // `conversation_id` targets an existing thread (inbox). `contact_id` + // lets a caller initiate from a contact that may have no conversation + // yet (Contact detail → Send template) — we find-or-create one below. + conversation_id: conversationIdInput, + contact_id, message_type, content_text, media_url, @@ -76,9 +80,12 @@ export async function POST(request: Request) { reply_to_message_id, } = body - if (!conversation_id || !message_type) { + if ((!conversationIdInput && !contact_id) || !message_type) { return NextResponse.json( - { error: 'conversation_id and message_type are required' }, + { + error: + 'Either conversation_id or contact_id, plus message_type, are required', + }, { status: 400 } ) } @@ -133,21 +140,69 @@ export async function POST(request: Request) { ) } - // Fetch conversation and contact - const { data: conversation, error: convError } = await supabase - .from('conversations') - .select('*, contact:contacts(*)') - .eq('id', conversation_id) - .eq('account_id', accountId) - .single() + // Resolve the target conversation. With `conversation_id` we load the + // existing thread; with `contact_id` we find-or-create one for the + // contact so a business-initiated template send (Contact detail view) + // reuses this whole path — phone variants, send-builder, persistence. + let conversation: { id: string; contact?: { id: string; phone?: string } | null } | null = null - if (convError || !conversation) { + if (conversationIdInput) { + const { data, error: convError } = await supabase + .from('conversations') + .select('*, contact:contacts(*)') + .eq('id', conversationIdInput) + .eq('account_id', accountId) + .single() + + if (convError || !data) { + return NextResponse.json( + { error: 'Conversation not found' }, + { status: 404 } + ) + } + conversation = data + } else { + // contact_id path: verify the contact is in this account first so a + // caller can't open a conversation against someone else's contact. + const { data: contactRow, error: contactErr } = await supabase + .from('contacts') + .select('*') + .eq('id', contact_id) + .eq('account_id', accountId) + .maybeSingle() + + if (contactErr || !contactRow) { + return NextResponse.json( + { error: 'Contact not found' }, + { status: 404 } + ) + } + + const resolved = await findOrCreateConversation( + supabase, + accountId, + user.id, + contact_id + ) + if (!resolved) { + return NextResponse.json( + { error: 'Failed to open a conversation for this contact' }, + { status: 500 } + ) + } + // The embed may not round-trip on insert; pin the contact we verified. + conversation = { ...resolved, contact: resolved.contact ?? contactRow } + } + + if (!conversation) { return NextResponse.json( { error: 'Conversation not found' }, { status: 404 } ) } + const conversation_id = conversation.id + const contact = conversation.contact if (!contact?.phone) { return NextResponse.json( @@ -442,3 +497,45 @@ export async function POST(request: Request) { ) } } + +type SendSupabase = Awaited> + +/** + * Return the contact's conversation in this account, creating one if it + * doesn't exist yet. Mirrors the webhook's find-or-create so an + * inbound-then-outbound (or outbound-first) sequence converges on a single + * thread per contact. Runs under the caller's RLS — the conversations_insert + * policy requires account agent membership, which the caller already is. + */ +async function findOrCreateConversation( + supabase: SendSupabase, + accountId: string, + userId: string, + contactId: string, +) { + const { data: existing } = await supabase + .from('conversations') + .select('*, contact:contacts(*)') + .eq('account_id', accountId) + .eq('contact_id', contactId) + .maybeSingle() + + if (existing) return existing + + const { data: created, error } = await supabase + .from('conversations') + .insert({ + account_id: accountId, + user_id: userId, + contact_id: contactId, + }) + .select('*, contact:contacts(*)') + .single() + + if (error) { + console.error('Error creating conversation for contact send:', error.message) + return null + } + + return created +} diff --git a/src/components/contacts/contact-detail-view.tsx b/src/components/contacts/contact-detail-view.tsx index ef57bfe..35a3982 100644 --- a/src/components/contacts/contact-detail-view.tsx +++ b/src/components/contacts/contact-detail-view.tsx @@ -5,7 +5,11 @@ import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; import { formatCurrency } from '@/lib/currency'; import { toast } from 'sonner'; -import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types'; +import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal, MessageTemplate } from '@/types'; +import { + TemplatePicker, + type TemplateSendValues, +} from '@/components/inbox/template-picker'; import { Sheet, SheetContent, @@ -33,6 +37,7 @@ import { Save, X, DollarSign, + LayoutTemplate, } from 'lucide-react'; interface ContactDetailViewProps { @@ -55,6 +60,12 @@ export function ContactDetailView({ const [loading, setLoading] = useState(false); const [copiedPhone, setCopiedPhone] = useState(false); + // Send template — lets the business initiate (or re-open) a conversation + // with this contact by sending an approved template. The send route + // find-or-creates the conversation, so no inbound message is required. + const [templatePickerOpen, setTemplatePickerOpen] = useState(false); + const [sendingTemplate, setSendingTemplate] = useState(false); + // Details tab const [editName, setEditName] = useState(''); const [editPhone, setEditPhone] = useState(''); @@ -317,6 +328,48 @@ export function ContactDetailView({ setSavingCustom(false); } + async function handleSendTemplate( + template: MessageTemplate, + values: TemplateSendValues, + ) { + if (!contactId) return; + setSendingTemplate(true); + try { + const res = await fetch('/api/whatsapp/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + // No conversation_id — the route find-or-creates one for this + // contact, mirroring the inbox template-send payload otherwise. + contact_id: contactId, + message_type: 'template', + template_name: template.name, + template_language: template.language, + template_message_params: { + body: values.body, + headerText: values.headerText, + buttonParams: values.buttonParams, + }, + template_params: values.body, + }), + }); + + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + const reason = payload?.error || `HTTP ${res.status}`; + toast.error(`Failed to send template: ${reason}`); + return; + } + + toast.success(`Template "${template.name}" sent`); + } catch (err) { + const reason = err instanceof Error ? err.message : 'network error'; + toast.error(`Failed to send template: ${reason}`); + } finally { + setSendingTemplate(false); + } + } + function getInitials(name?: string | null) { if (!name) return '?'; return name @@ -328,6 +381,7 @@ export function ContactDetailView({ } return ( + <> +
+ +
{/* Tabs */} @@ -684,5 +753,11 @@ export function ContactDetailView({ )}
+ + ); }