feat(contacts): send an approved template to a single contact (#296)

Adds a "Send template" action to the contact detail view so the
business can initiate (or re-open) a WhatsApp conversation with one
known contact by sending an approved template — without the awkward
single-recipient broadcast workaround.

The /api/whatsapp/send route now accepts `contact_id` as an
alternative to `conversation_id`: it verifies the contact is in the
caller's account, find-or-creates the contact's conversation
server-side, then runs the existing send path (phone-variant retry,
template send-builder, message + conversation persistence, flow
pause). The contact view reuses the existing inbox TemplatePicker for
template selection + variable/header/button input, keeping Meta
compliance unchanged (approved templates only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-29 17:11:02 +03:00
parent 30cc7a6ab2
commit 19e35accb6
No known key found for this signature in database
3 changed files with 430 additions and 12 deletions

View File

@ -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<Record<string, unknown>> = []
const messageInserts: Array<Record<string, unknown>> = []
// Toggles for the per-test scenario.
let existingConversation: Record<string, unknown> | null = null
let contactRow: Record<string, unknown> | 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<string, unknown> = {}
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<string, unknown>) => {
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<string, unknown> = {}
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<string, unknown> = {}) {
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)
})
})

View File

@ -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,20 +140,68 @@ export async function POST(request: Request) {
)
}
// Fetch conversation and contact
const { data: conversation, error: convError } = await supabase
// 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 (conversationIdInput) {
const { data, error: convError } = await supabase
.from('conversations')
.select('*, contact:contacts(*)')
.eq('id', conversation_id)
.eq('id', conversationIdInput)
.eq('account_id', accountId)
.single()
if (convError || !conversation) {
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) {
@ -442,3 +497,45 @@ export async function POST(request: Request) {
)
}
}
type SendSupabase = Awaited<ReturnType<typeof createClient>>
/**
* 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
}

View File

@ -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 (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
@ -382,6 +436,21 @@ export function ContactDetailView({
</div>
</div>
</div>
<div className="mt-3">
<Button
size="sm"
onClick={() => setTemplatePickerOpen(true)}
disabled={sendingTemplate}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{sendingTemplate ? (
<Loader2 className="size-4 animate-spin" />
) : (
<LayoutTemplate className="size-4" />
)}
Send template
</Button>
</div>
</SheetHeader>
{/* Tabs */}
@ -684,5 +753,11 @@ export function ContactDetailView({
)}
</SheetContent>
</Sheet>
<TemplatePicker
open={templatePickerOpen}
onOpenChange={setTemplatePickerOpen}
onSelect={handleSendTemplate}
/>
</>
);
}