mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
30cc7a6ab2
commit
19e35accb6
246
src/app/api/whatsapp/send/route.test.ts
Normal file
246
src/app/api/whatsapp/send/route.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user